From 9f663f4cba732377e4f6e93f70736e41cc020f34 Mon Sep 17 00:00:00 2001 From: Hugo Vinicius Garcia Razera Date: Fri, 1 Aug 2025 13:34:37 +0000 Subject: [PATCH 1/4] feat(storage): Hitachi, Purestorage, DellSC via Sunbeam CLI fix: tox -e fmt, pep8, mypy reimplement Unit testing to conform to new signatures and changes to use terraform Fix storage backend removal regression and address reviewer comments - Fix partial removal regression: write updated Terraform variables before apply - Remove global registry instance, follow Provider pattern for CLI registration - Add Juju application name validation to prevent invalid backend names - Implement atomic removal operations with proper rollback on failure - Add cleanup-config command to resolve invalid backend configurations - Improve error handling and logging throughout storage backend system - Update deprecated Pydantic __fields__ usage for v2 compatibility - Consolidate imports and reduce emoji usage in console output refactor: clean up Hitachi backend CLI and eliminate code duplication - Move CLI code to dedicated module (hitachi/cli.py) with proper separation - Remove duplicate backend_exists() methods, consolidate in service layer - Eliminate unused service methods (set/reset_backend_config) and tests - Refactor duplicate config filtering logic into reusable internal function - Clean up storage backend __init__.py files to match provider pattern - Update all CLI and backend code to use service layer properly - Fix missing abstract method implementations in test mocks - Update test mocking to reflect architectural changes Signed-off-by: Hugo Vinicius Garcia Razera - Retry apply and destroy plans on terraform locks - Shows friendly error messege on state locks - Verify juju login status, before add and remove. Signed-off-by: Hugo Vinicius Garcia Razera - Refactor common Backend Methods into Base Class - Implement PureStorage Backend - separate terraforms-key for each backend - Implementaion of Dell Storage Center Backend - fix show config options for pure storage --- sunbeam-python/sunbeam/commands/storage.py | 46 ++ sunbeam-python/sunbeam/core/common.py | 42 ++ sunbeam-python/sunbeam/main.py | 5 + sunbeam-python/sunbeam/storage/__init__.py | 9 + .../sunbeam/storage/backends/__init__.py | 7 + .../storage/backends/dellsc/__init__.py | 9 + .../storage/backends/dellsc/backend.py | 397 +++++++++++ .../sunbeam/storage/backends/dellsc/cli.py | 344 +++++++++ .../dellsc/deploy-dellsc-backend/main.tf | 140 ++++ .../dellsc/deploy-dellsc-backend/outputs.tf | 13 + .../test-variables.tfvars | 41 ++ .../dellsc/deploy-dellsc-backend/variables.tf | 65 ++ .../storage/backends/hitachi/backend.py | 608 ++++++++++++++++ .../sunbeam/storage/backends/hitachi/cli.py | 344 +++++++++ .../hitachi/deploy-hitachi-backend/main.tf | 222 ++++++ .../hitachi/deploy-hitachi-backend/outputs.tf | 13 + .../test-variables.tfvars | 35 + .../deploy-hitachi-backend/variables.tf | 74 ++ .../storage/backends/purestorage/backend.py | 407 +++++++++++ .../storage/backends/purestorage/cli.py | 346 +++++++++ .../deploy-purestorage-backend/main.tf | 56 ++ .../deploy-purestorage-backend/outputs.tf | 13 + .../deploy-purestorage-backend/variables.tf | 58 ++ sunbeam-python/sunbeam/storage/base.py | 670 ++++++++++++++++++ sunbeam-python/sunbeam/storage/models.py | 59 ++ sunbeam-python/sunbeam/storage/registry.py | 192 +++++ sunbeam-python/sunbeam/storage/service.py | 248 +++++++ sunbeam-python/sunbeam/storage/steps.py | 642 +++++++++++++++++ .../unit/sunbeam/storage/backends/__init__.py | 4 + .../storage/backends/hitachi/__init__.py | 4 + .../storage/backends/hitachi/test_cli.py | 367 ++++++++++ .../tests/unit/sunbeam/storage/conftest.py | 275 +++++++ .../unit/sunbeam/storage/test_hitachi.py | 575 +++++++++++++++ .../tests/unit/sunbeam/storage/test_models.py | 170 +++++ .../unit/sunbeam/storage/test_registry.py | 504 +++++++++++++ .../unit/sunbeam/storage/test_service.py | 206 ++++++ .../unit/sunbeam/storage/test_storage_base.py | 296 ++++++++ .../sunbeam/storage/test_storage_steps.py | 594 ++++++++++++++++ 38 files changed, 8100 insertions(+) create mode 100644 sunbeam-python/sunbeam/commands/storage.py create mode 100644 sunbeam-python/sunbeam/storage/__init__.py create mode 100644 sunbeam-python/sunbeam/storage/backends/__init__.py create mode 100644 sunbeam-python/sunbeam/storage/backends/dellsc/__init__.py create mode 100644 sunbeam-python/sunbeam/storage/backends/dellsc/backend.py create mode 100644 sunbeam-python/sunbeam/storage/backends/dellsc/cli.py create mode 100644 sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/main.tf create mode 100644 sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/outputs.tf create mode 100644 sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/test-variables.tfvars create mode 100644 sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/variables.tf create mode 100644 sunbeam-python/sunbeam/storage/backends/hitachi/backend.py create mode 100644 sunbeam-python/sunbeam/storage/backends/hitachi/cli.py create mode 100644 sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/main.tf create mode 100644 sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/outputs.tf create mode 100644 sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/test-variables.tfvars create mode 100644 sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/variables.tf create mode 100644 sunbeam-python/sunbeam/storage/backends/purestorage/backend.py create mode 100644 sunbeam-python/sunbeam/storage/backends/purestorage/cli.py create mode 100644 sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/main.tf create mode 100644 sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/outputs.tf create mode 100644 sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/variables.tf create mode 100644 sunbeam-python/sunbeam/storage/base.py create mode 100644 sunbeam-python/sunbeam/storage/models.py create mode 100644 sunbeam-python/sunbeam/storage/registry.py create mode 100644 sunbeam-python/sunbeam/storage/service.py create mode 100644 sunbeam-python/sunbeam/storage/steps.py create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/backends/__init__.py create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/backends/hitachi/__init__.py create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/backends/hitachi/test_cli.py create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/conftest.py create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/test_hitachi.py create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/test_models.py create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/test_registry.py create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/test_service.py create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/test_storage_base.py create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/test_storage_steps.py diff --git a/sunbeam-python/sunbeam/commands/storage.py b/sunbeam-python/sunbeam/commands/storage.py new file mode 100644 index 000000000..34fa9c3bd --- /dev/null +++ b/sunbeam-python/sunbeam/commands/storage.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +import logging + +import click +from rich.console import Console + +from sunbeam.core.deployment import Deployment +from sunbeam.storage.registry import StorageBackendRegistry + +LOG = logging.getLogger(__name__) +console = Console() + +CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} + + +@click.group("storage", context_settings=CONTEXT_SETTINGS) +@click.pass_context +def storage(ctx): + """Manage Cinder storage backends. + + Provides commands to add, remove, configure and list storage backends. + Supports multiple backend types including Hitachi VSP and others. + """ + # Ensure we have a deployment object + if not hasattr(ctx, "obj") or not isinstance(ctx.obj, Deployment): + raise click.ClickException( + "Storage commands require a valid deployment context. " + "Please ensure sunbeam is properly initialized." + ) + + +def register_storage_commands(deployment: Deployment) -> None: + """Register storage backend commands with the storage group. + + This function is called from main.py to register all storage backend + commands dynamically based on available backends. + """ + try: + registry = StorageBackendRegistry() + registry.register_cli_commands(storage, deployment) + LOG.debug("Storage backend commands registered successfully") + except Exception as e: + LOG.error(f"Failed to register storage backend commands: {e}") + # Don't raise here as we want the CLI to still work diff --git a/sunbeam-python/sunbeam/core/common.py b/sunbeam-python/sunbeam/core/common.py index 85f09dafc..6f31c1c6a 100644 --- a/sunbeam-python/sunbeam/core/common.py +++ b/sunbeam-python/sunbeam/core/common.py @@ -651,3 +651,45 @@ def convert_retry_failure_as_result(retry_state: RetryCallState) -> Result: return Result(ResultType.FAILED, str(retry_state.outcome.exception())) else: return Result(ResultType.FAILED) + + +def friendly_terraform_lock_retry_callback(retry_state: RetryCallState) -> Result: + """Friendly retry callback for Terraform state lock exceptions. + + Shows user-friendly messages during lock retries + instead of verbose Terraform output. + """ + from sunbeam.core.terraform import TerraformStateLockedException + + if retry_state.outcome is not None: + exception = retry_state.outcome.exception() + if isinstance(exception, TerraformStateLockedException): + # Extract lock ID from the error message if possible + lock_id = "unknown" + error_str = str(exception) + if "ID:" in error_str: + try: + # Extract lock ID from Terraform output + lines = error_str.split("\n") + for line in lines: + if "ID:" in line: + lock_id = line.split("ID:")[1].strip() + break + except Exception: + LOG.debug( + "Failed to extract lock ID from Terraform output: %s", + error_str, + ) + pass + + return Result( + ResultType.FAILED, + f"Terraform state is locked (ID: {lock_id}). " + f"This usually resolves automatically. " + f"If it persists, use 'sunbeam plans unlock ' to " + f"clear stale locks.", + ) + else: + return Result(ResultType.FAILED, str(exception)) + else: + return Result(ResultType.FAILED, "Operation failed after retries") diff --git a/sunbeam-python/sunbeam/main.py b/sunbeam-python/sunbeam/main.py index 0ebc6f30f..0debe1681 100644 --- a/sunbeam-python/sunbeam/main.py +++ b/sunbeam-python/sunbeam/main.py @@ -20,6 +20,7 @@ from sunbeam.commands import prepare_node as prepare_node_cmds from sunbeam.commands import proxy as proxy_cmds from sunbeam.commands import sso as sso_cmd +from sunbeam.commands import storage as storage_cmds from sunbeam.commands import utils as utils_cmds from sunbeam.core import deployments as deployments_jobs from sunbeam.provider import commands as provider_cmds @@ -113,6 +114,7 @@ def main(): cli.add_command(launch_cmds.launch) cli.add_command(openrc_cmds.openrc) cli.add_command(dasboard_url_cmds.dashboard_url) + cli.add_command(storage_cmds.storage) # Add identity group cli.add_command(identity_group) @@ -160,6 +162,9 @@ def main(): juju.add_command(juju_cmds.register_controller) juju.add_command(juju_cmds.unregister_controller) + # Register storage backend commands + storage_cmds.register_storage_commands(deployment) + # Register the features after all groups,commands are registered deployment.get_feature_manager().register(cli, deployment) diff --git a/sunbeam-python/sunbeam/storage/__init__.py b/sunbeam-python/sunbeam/storage/__init__.py new file mode 100644 index 000000000..005b6db0d --- /dev/null +++ b/sunbeam-python/sunbeam/storage/__init__.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Sunbeam Storage Backends. + +This module provides a pluggable storage backend system for Sunbeam. +""" + +# Backends are loaded dynamically by the registry - no hardcoded imports needed diff --git a/sunbeam-python/sunbeam/storage/backends/__init__.py b/sunbeam-python/sunbeam/storage/backends/__init__.py new file mode 100644 index 000000000..f4e0ac8a7 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/__init__.py @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Sunbeam Storage Backend Implementations. + +This package contains implementations of various storage backends for Sunbeam. +""" diff --git a/sunbeam-python/sunbeam/storage/backends/dellsc/__init__.py b/sunbeam-python/sunbeam/storage/backends/dellsc/__init__.py new file mode 100644 index 000000000..dbc28643e --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/dellsc/__init__.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Dell Storage Center backend for Sunbeam storage.""" + +from .backend import DellSCBackend, DellSCConfig +from .cli import DellscCLI + +__all__ = ["DellSCBackend", "DellSCConfig", "DellscCLI"] \ No newline at end of file diff --git a/sunbeam-python/sunbeam/storage/backends/dellsc/backend.py b/sunbeam-python/sunbeam/storage/backends/dellsc/backend.py new file mode 100644 index 000000000..1f257b4a6 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/dellsc/backend.py @@ -0,0 +1,397 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Dell Storage Center storage backend implementation using base step classes.""" + +import logging +from typing import Any, Dict, Literal, Set + +import click + +try: + import yaml as _yaml # type: ignore + + yaml: Any = _yaml +except Exception: # yaml optional; handle gracefully at runtime + yaml = None + +# Import pydantic Field directly +from pydantic import Field +from rich.console import Console + +from sunbeam.core.common import BaseStep +from sunbeam.core.deployment import Deployment +from sunbeam.core.juju import JujuHelper +from sunbeam.core.manifest import Manifest +from sunbeam.core.terraform import TerraformHelper +from sunbeam.storage.base import StorageBackendBase +from sunbeam.storage.models import StorageBackendConfig +from sunbeam.storage.steps import ( + BaseStorageBackendConfigUpdateStep, + BaseStorageBackendDeployStep, + BaseStorageBackendDestroyStep, +) + +LOG = logging.getLogger(__name__) +console = Console() + + +class DellSCConfig(StorageBackendConfig): + """Static configuration model for Dell Storage Center storage backend. + + This model includes all configuration options supported by the + cinder-volume-dellsc charm as defined in charmcraft.yaml. + """ + + # Required fields (inherited from StorageBackendConfig) + # name: str (from base class) + + # Mandatory connection parameters + san_ip: str = Field(..., description="Dell Storage Center management IP or hostname") + san_username: str = Field(..., description="SAN management username") + san_password: str = Field(..., description="SAN management password") + dell_sc_ssn: int = Field(default=64702, description="Storage Center System Serial Number") + protocol: Literal["fc", "iscsi"] = Field( + default="fc", description="Front-end protocol (fc or iscsi)" + ) + + # Backend configuration + volume_backend_name: str = Field( + default="", description="Name that Cinder will report for this backend" + ) + backend_availability_zone: str = Field( + default="", description="Availability zone to associate with this backend" + ) + + # Dell Storage Center specific options + dell_sc_api_port: int = Field( + default=3033, description="Dell Storage Center API port" + ) + dell_sc_server_folder: str = Field( + default="openstack", description="Server folder name on Dell SC" + ) + dell_sc_volume_folder: str = Field( + default="openstack", description="Volume folder name on Dell SC" + ) + dell_server_os: str = Field( + default="Red Hat Linux 6.x", description="Server OS type for Dell SC" + ) + dell_sc_verify_cert: bool = Field( + default=False, description="Verify SSL certificate for Dell SC API" + ) + + # Provisioning options + san_thin_provision: bool = Field( + default=True, description="Enable thin provisioning" + ) + + # Domain and network filtering + excluded_domain_ips: str = Field( + default="", description="Comma-separated list of excluded domain IPs" + ) + included_domain_ips: str = Field( + default="", description="Comma-separated list of included domain IPs" + ) + + # Dual DSM configuration + secondary_san_ip: str = Field( + default="", description="Secondary Dell Storage Center management IP" + ) + secondary_san_username: str = Field( + default="Admin", description="Secondary SAN management username" + ) + secondary_san_password: str = Field( + default="", description="Secondary SAN management password" + ) + secondary_sc_api_port: int = Field( + default=3033, description="Secondary Dell Storage Center API port" + ) + + # API timeout configuration + dell_api_async_rest_timeout: int = Field( + default=15, description="Async REST API timeout in seconds" + ) + dell_api_sync_rest_timeout: int = Field( + default=30, description="Sync REST API timeout in seconds" + ) + + # SSH connection settings + ssh_conn_timeout: int = Field( + default=30, description="SSH connection timeout in seconds" + ) + ssh_max_pool_conn: int = Field( + default=5, description="Maximum SSH pool connections" + ) + ssh_min_pool_conn: int = Field( + default=1, description="Minimum SSH pool connections" + ) + + # Juju secrets for credentials (not charm config options) + san_credentials_secret: str = Field( + default="", description="Juju secret URI for SAN credentials" + ) + secondary_san_credentials_secret: str = Field( + default="", description="Juju secret URI for secondary SAN credentials" + ) + + +class DellSCBackend(StorageBackendBase): + """Dell Storage Center storage backend implementation.""" + + name = "dellsc" + display_name = "Dell Storage Center" + charm_name = "cinder-volume-dellsc" + + def __init__(self): + """Initialize Dell Storage Center backend.""" + super().__init__() + self.tfplan = "dellsc-backend-plan" + self.tfplan_dir = "deploy-dellsc-backend" + + charm_channel = ( + "latest/edge" # Use edge for development, change to stable for production + ) + charm_revision = 3 + charm_base = "ubuntu@24.04" + backend_endpoint = "cinder-volume" + units = 1 + additional_integrations = [] + + @property + def config_class(self) -> type[StorageBackendConfig]: + """Return the configuration class for Dell Storage Center backend.""" + return DellSCConfig + + def _get_credential_fields(self) -> Set[str]: + """Get set of credential field names that should be excluded from charm config. + + For Dell SC backend, we exclude all credential fields and secret URIs. + """ + return { + # meta + "name", + # primary array credentials + "san_username", + "san_password", + # secondary array credentials + "secondary_san_username", + "secondary_san_password", + # juju secret URIs + "san_credentials_secret", + "secondary_san_credentials_secret", + } + + def get_field_mapping(self) -> Dict[str, str]: + """Get mapping from config fields to charm config options. + + Uses base class automatic mapping and excludes credential fields. + """ + # Start from the base automatic mapping + mapping = super().get_field_mapping() + + # Exclude credential fields + exclude = self._get_credential_fields() + return {k: v for k, v in mapping.items() if k not in exclude} + + def get_terraform_variables( + self, backend_name: str, config: StorageBackendConfig, model: str + ) -> Dict[str, Any]: + """Generate Terraform variables for Dell Storage Center backend deployment.""" + # Map our configuration fields to the correct charm configuration option names + config_dict = config.model_dump() + field_mapping = self.get_field_mapping() + + # Filter config using base class method, excluding credential fields + charm_config = self._filter_config_for_charm( + config_dict, field_mapping, exclude_fields=self._get_credential_fields() + ) + + # Build Terraform variables to match the plan's expected format + tfvars = { + "machine_model": model, + "charm_dellsc_name": self.charm_name, + "charm_dellsc_base": self.charm_base, + "charm_dellsc_channel": self.charm_channel, + "charm_dellsc_endpoint": self.backend_endpoint, + "charm_dellsc_revision": self.charm_revision, + "dellsc_backends": { + backend_name: { + "charm_config": charm_config, + # Main array credentials (always required) + "san_username": config_dict.get("san_username", ""), + "san_password": config_dict.get("san_password", ""), + # Secondary array credentials (optional) + "secondary_san_username": config_dict.get("secondary_san_username", ""), + "secondary_san_password": config_dict.get("secondary_san_password", ""), + } + }, + } + + return tfvars + + def _get_default_config(self) -> DellSCConfig: + """Get a default configuration instance for comparison. + + This creates a config instance with all fields set to their Pydantic defaults, + allowing proper filtering of default values in _filter_config_for_charm(). + """ + # Create instance with minimal required fields, letting Pydantic set defaults + return DellSCConfig( + name="dummy", # Required field + san_ip="dummy", # Required field + san_username="dummy", # Required field + san_password="dummy", # Required field, noqa: S106 + # All other fields will use their Pydantic Field() defaults: + # dell_sc_ssn=64702, protocol="fc", volume_backend_name="", + # backend_availability_zone="", dell_sc_api_port=3033, etc. + ) + + def prompt_for_config(self, backend_name: str) -> DellSCConfig: + """Prompt user for Dell Storage Center-specific configuration.""" + return self._prompt_for_config(backend_name) + + def _prompt_for_config(self, backend_name: str) -> DellSCConfig: + """Prompt user for Dell Storage Center backend configuration.""" + console.print( + "\n[bold blue]Dell Storage Center Backend Configuration[/bold blue]" + ) + console.print("Please provide the required configuration options:") + + # Prompt for required fields + san_ip = click.prompt( + "Management IP/FQDN", type=str, value_proc=self._validate_ip_or_fqdn + ) + dell_sc_ssn = click.prompt( + "Storage Center System Serial Number", type=int, default=64702 + ) + protocol = click.prompt( + "Protocol", + type=click.Choice(["fc", "iscsi"], case_sensitive=False), + default="fc", + ) + + # Main array credentials (will be automatically converted to Juju secret) + console.print("\n[bold yellow]Array Credentials[/bold yellow]") + console.print( + "These credentials will be automatically stored in a Juju secret." + ) + san_username = click.prompt("SAN username", type=str, default="admin") + san_password = click.prompt("SAN password", type=str, hide_input=True) + + # Optional: prompt for volume backend name (defaults to backend name) + volume_backend_name = click.prompt( + "Volume backend name", type=str, default=backend_name, show_default=True + ) + + # Optional: Dual DSM configuration + secondary_san_username = "" + secondary_san_password = "" + secondary_san_ip = "" + + configure_dual_dsm = click.confirm( + "\nConfigure dual DSM (high availability)?", default=False + ) + if configure_dual_dsm: + console.print("\n[bold yellow]Secondary DSM Configuration[/bold yellow]") + secondary_san_ip = click.prompt( + "Secondary management IP/FQDN", type=str, value_proc=self._validate_ip_or_fqdn + ) + secondary_san_username = click.prompt( + "Secondary SAN username", type=str, default="Admin" + ) + secondary_san_password = click.prompt( + "Secondary SAN password", type=str, hide_input=True + ) + + return DellSCConfig( + name=backend_name, + san_ip=san_ip, + san_username=san_username, + san_password=san_password, + dell_sc_ssn=dell_sc_ssn, + protocol=protocol, + volume_backend_name=volume_backend_name, + secondary_san_ip=secondary_san_ip, + secondary_san_username=secondary_san_username, + secondary_san_password=secondary_san_password, + ) + + # Implementation of abstract methods from StorageBackendBase + def create_deploy_step( + self, + deployment: Deployment, + client, + tfhelper: TerraformHelper, + jhelper: JujuHelper, + manifest: Manifest, + backend_name: str, + backend_config: StorageBackendConfig, + model: str, + ) -> BaseStep: + """Create a deployment step for Dell Storage Center backend.""" + return DellSCDeployStep( + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + backend_config, + self, + model, + ) + + def create_destroy_step( + self, + deployment: Deployment, + client, + tfhelper: TerraformHelper, + jhelper: JujuHelper, + manifest: Manifest, + backend_name: str, + model: str, + ) -> BaseStep: + """Create a destruction step for Dell Storage Center backend.""" + return DellSCDestroyStep( + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + self, + model, + ) + + def create_update_config_step( + self, + deployment: Deployment, + backend_name: str, + config_updates: Dict[str, Any], + ) -> BaseStep: + """Create a configuration update step for Dell Storage Center backend.""" + return DellSCUpdateConfigStep( + deployment, + self, + backend_name, + config_updates, + ) + + +# Dell Storage Center-specific step implementations using base step classes +class DellSCDeployStep(BaseStorageBackendDeployStep): + """Deploy Dell Storage Center storage backend using base step class.""" + + def get_terraform_variables(self) -> Dict[str, Any]: + """Get Terraform variables for Dell Storage Center backend deployment.""" + return self.backend_instance.get_terraform_variables( + self.backend_name, self.backend_config, self.model + ) + + +class DellSCDestroyStep(BaseStorageBackendDestroyStep): + """Destroy Dell Storage Center storage backend using base step class.""" + + +class DellSCUpdateConfigStep(BaseStorageBackendConfigUpdateStep): + """Update Dell Storage Center storage backend configuration using base step class.""" diff --git a/sunbeam-python/sunbeam/storage/backends/dellsc/cli.py b/sunbeam-python/sunbeam/storage/backends/dellsc/cli.py new file mode 100644 index 000000000..ad8e94a16 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/dellsc/cli.py @@ -0,0 +1,344 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""CLI functionality for Dell Storage Center storage backend. + +This module contains all CLI-related code moved from backend.py, +including command registration and helper functions. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, Optional + +import click +import pydantic +from rich.console import Console + +try: + import yaml as _yaml # type: ignore + + yaml: Any = _yaml +except Exception: # yaml optional; handle gracefully at runtime + yaml = None + +from sunbeam.core.deployment import Deployment +from sunbeam.storage.backends.dellsc.backend import DellSCBackend +from sunbeam.storage.service import StorageBackendService + +console = Console() + + +class DellscCLI: + """CLI functionality for Dell Storage Center storage backend.""" + + def __init__(self, backend: DellSCBackend): + self.backend = backend + + def _load_config_file(self, path: Optional[Path]) -> Dict[str, Any]: + """Load YAML or JSON config file into a dictionary. + + YAML is preferred if PyYAML is available, otherwise JSON is used. + """ + if not path: + return {} + text = path.read_text() + if yaml is not None: + return dict(yaml.safe_load(text) or {}) + + return dict(json.loads(text)) + + def register_add_cli(self, add: click.Group) -> None: # noqa: C901 + """Register 'sunbeam storage add dellsc'. + + Includes typed options and a --config-file flag. + """ + + def _click_type_for(field_info) -> click.types.ParamType: + # Map pydantic field to Click type + ann = getattr(field_info, "annotation", None) + typ = None + if ann is not None: + typ = str(ann) + elif hasattr(field_info, "type_"): + typ = str(field_info.type_) + if typ and ("int" in typ): + return click.INT + if typ and ("float" in typ): + return click.FLOAT + if typ and ("bool" in typ): + return click.BOOL + return click.STRING + + def _build_params(required_all: bool) -> list: + params: list = [] + # name option (not required; prompt in interactive mode) + params.append( + click.Option(["--name"], type=str, required=False, help="Backend name") + ) + # config file (optional) + params.append( + click.Option( + ["--config-file"], + type=click.Path(exists=True, dir_okay=False, path_type=Path), + required=False, + help="YAML/JSON config file", + ) + ) + # Model-derived options + fields = getattr(self.backend.config_class, "model_fields", {}) + for fname, finfo in fields.items(): + if fname == "name": + continue + opt_name = "--" + fname.replace("_", "-") + click_type = _click_type_for(finfo) + # Determine requiredness for add (respect model) + # For interactive UX, keep CLI options optional; the model + # enforces requiredness. + is_required = False + # Help text + descr = None + if hasattr(finfo, "field_info") and hasattr( + finfo.field_info, "description" + ): + descr = finfo.field_info.description + elif hasattr(finfo, "description"): + descr = finfo.description + params.append( + click.Option( + [opt_name], type=click_type, required=is_required, help=descr + ) + ) + return params + + def _build_config_from_kwargs(kwargs: Dict[str, Any]) -> Dict[str, Any]: + # Extract name and field values from kwargs + # (Click converts dashes to underscores) + cfg: Dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + cfg[k] = v + return cfg + + def add_callback(**kwargs): + deployment: Deployment = click.get_current_context().obj + cfg_file = kwargs.pop("config_file", None) + file_cfg = self._load_config_file(cfg_file) + cli_cfg = _build_config_from_kwargs(kwargs) + # Determine if interactive: no config-file and no CLI options supplied + provided_cli_values = {k: v for k, v in cli_cfg.items() if v is not None} + interactive = not file_cfg and not provided_cli_values + + if interactive: + # Prompt for name and full config via helper + console.print( + f"[blue]Setting up {self.backend.display_name} backend[/blue]" + ) + backend_name = click.prompt("Backend name", type=str) + config_instance = self.backend.prompt_for_config(backend_name) + config_instance.name = backend_name + self.backend.add_backend( + deployment, backend_name, config_instance, console + ) + return + + # Non-interactive path: merge file and CLI, validate + merged = {**file_cfg, **provided_cli_values} + try: + config_instance = self.backend.config_class(**merged) + except pydantic.ValidationError as e: + console.print("[red]Configuration validation error:[/red]") + for error in e.errors(): + field_name = error.get("loc", ["unknown"])[0] + # Convert field name to CLI parameter format + cli_param = f"--{field_name.replace('_', '-')}" + console.print(f" {cli_param}: {error['msg']}") + raise click.Abort() + backend_name = merged.get("name") + if not backend_name: + raise click.BadParameter( + "--name is required when not running interactively" + ) + self.backend.add_backend(deployment, backend_name, config_instance, console) + + # Build command dynamically with parameters + params = _build_params(required_all=False) + help_text = ( + f"Add {self.backend.display_name} backend.\n\n" + "Behavior:\n" + "- If no options are provided, runs in interactive mode and prompts " + "for all required fields.\n" + "- If options and/or --config-file are provided, runs non-" + "interactively and validates against the model.\n" + "- In non-interactive mode, --name is required (or supplied via " + "--config-file).\n\n" + "Examples:\n" + " sunbeam storage add dellsc\n" + " sunbeam storage add dellsc --config-file dellsc.yaml\n" + " sunbeam storage add dellsc --name mydellsc --san-ip 10.0.0.10 " + "--protocol fc\n" + ) + cmd = click.Command( + name=self.backend.name, + params=params, + callback=add_callback, + help=help_text, + ) + add.add_command(cmd) + + def register_cli( # noqa: C901 + self, + remove: click.Group, + config_show: click.Group, + config_set: click.Group, + config_options: click.Group, + deployment: Deployment, + ) -> None: + """Register management commands for Dell Storage Center backend.""" + + @click.command(name=self.backend.name) + @click.argument("backend_name", type=str) + @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") + @click.pass_context + def remove_dellsc(ctx, backend_name: str, yes: bool): + service = self.backend._get_service(deployment) + if not service.backend_exists(backend_name, self.backend.name): + console.print(f"[red]Error: Backend '{backend_name}' not found[/red]") + raise click.Abort() + if not yes: + click.confirm( + f"Remove {self.backend.display_name} backend '{backend_name}'?", + abort=True, + ) + try: + self.backend.remove_backend(deployment, backend_name, console) + except Exception as e: + console.print(f"[red]Error removing backend: {e}[/red]") + raise click.Abort() + + remove.add_command(remove_dellsc) + + @click.command(name=self.backend.name) + @click.argument("backend_name", type=str) + @click.pass_context + def config_show_dellsc(ctx, backend_name: str): + service = self.backend._get_service(deployment) + config = service.get_backend_config(backend_name, self.backend.name) + self.backend.display_config_table(backend_name, config) + + config_show.add_command(config_show_dellsc) + + # Build typed options for config set (only provided options are updated) + def _build_set_params() -> list: + params = [] + # Add config-file option first + params.append( + click.Option( + ["--config-file"], + type=click.Path( + exists=True, dir_okay=False, readable=True, path_type=Path + ), + required=False, + help="YAML/JSON config file with updates", + ) + ) + fields = getattr(self.backend.config_class, "model_fields", {}) + for fname, finfo in fields.items(): + if fname == "name": + continue + opt = "--" + fname.replace("_", "-") + # For updates, make everything optional with default None + # so we can detect presence + click_type: click.ParamType = click.STRING + ann = getattr(finfo, "annotation", None) + if ann is not None and ("bool" in str(ann)): + click_type = click.BOOL + elif ann is not None and ("int" in str(ann)): + click_type = click.INT + elif ann is not None and ("float" in str(ann)): + click_type = click.FLOAT + descr = None + if hasattr(finfo, "field_info") and hasattr( + finfo.field_info, "description" + ): + descr = finfo.field_info.description + elif hasattr(finfo, "description"): + descr = finfo.description + params.append( + click.Option( + [opt], type=click_type, required=False, default=None, help=descr + ) + ) + return params + + def set_callback(backend_name: str, **kwargs): + cfg_file = kwargs.pop("config_file", None) + file_cfg = {} + if cfg_file: + file_cfg = self._load_config_file(cfg_file) + # Only include keys that were explicitly provided (value not None) + updates = {k: v for k, v in kwargs.items() if v is not None} + updates = {**file_cfg, **updates} + try: + # Get current configuration and merge with updates for validation + service = StorageBackendService(deployment) + current_config = service.get_backend_config( + backend_name, self.backend.name + ) + + # Create a merged config for validation + # Current config comes from charm (kebab-case keys), + # convert to snake_case + merged_config = {} + for key, value in current_config.items(): + snake_key = key.replace("-", "_") + merged_config[snake_key] = value + + # Updates from CLI are already in snake_case + # (Click converts --protocol to protocol) + merged_config.update(updates) + merged_config["name"] = backend_name # Ensure name is set + _ = self.backend.config_class(**merged_config) + except pydantic.ValidationError as e: + console.print("[red]Configuration validation error:[/red]") + for error in e.errors(): + field_name = error["loc"][0] if error.get("loc") else "unknown" + # Convert field name to CLI parameter format + cli_param = f"--{str(field_name).replace('_', '-')}" + console.print(f" {cli_param}: {error['msg']}") + raise click.ClickException( + "Configuration update failed due to validation error" + ) + try: + self.backend.update_backend_config(deployment, backend_name, updates) + console.print( + ( + f"[green]Configuration updated for {self.backend.display_name} " + f"backend '{backend_name}'[/green]" + ) + ) + except Exception as e: + console.print(f"[red]Failed to update configuration: {e}[/red]") + raise click.ClickException(f"Configuration update failed: {e}") + + set_params = _build_set_params() + # Add backend_name as a required argument + set_params.insert(0, click.Argument(["backend_name"], type=str, required=True)) + set_cmd = click.Command( + name=self.backend.name, + params=set_params, + callback=set_callback, + help="Set configuration options", + ) + config_set.add_command(set_cmd) + + @click.command(name=self.backend.name) + @click.argument("backend_name", type=str, required=False) + @click.pass_context + def config_options_dellsc(ctx, backend_name: str | None = None): + self.backend.display_config_options() + + config_options.add_command(config_options_dellsc) diff --git a/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/main.tf b/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/main.tf new file mode 100644 index 000000000..35eb78acd --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/main.tf @@ -0,0 +1,140 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +terraform { + required_providers { + juju = { + source = "juju/juju" + version = "= 0.20.0" + } + } +} + +provider "juju" {} + +data "juju_model" "machine_model" { + name = var.machine_model +} + +# Create Juju secrets for Dell Storage Center backend credentials +# Main array credentials (required for all backends) +resource "juju_secret" "dellsc_san_credentials" { + for_each = { + for backend_name, backend_config in var.dellsc_backends : + backend_name => backend_config + if backend_config.san_username != "" && backend_config.san_password != "" + } + + model = data.juju_model.machine_model.name + name = "${each.key}-san-credentials" + value = { + username = each.value.san_username + password = each.value.san_password + } +} + +# Secondary array credentials (optional, for dual DSM) +resource "juju_secret" "dellsc_secondary_san_credentials" { + for_each = { + for backend_name, backend_config in var.dellsc_backends : + backend_name => backend_config + if backend_config.secondary_san_username != "" && backend_config.secondary_san_password != "" + } + + model = data.juju_model.machine_model.name + name = "${each.key}-secondary-san-credentials" + value = { + username = each.value.secondary_san_username + password = each.value.secondary_san_password + } +} + +# Grant access to secrets for Dell Storage Center backend applications +resource "juju_access_secret" "dellsc_san_credentials_access" { + for_each = { + for backend_name, backend_config in var.dellsc_backends : + backend_name => backend_config + if backend_config.san_username != "" && backend_config.san_password != "" + } + + model = data.juju_model.machine_model.name + secret_id = juju_secret.dellsc_san_credentials[each.key].secret_id + applications = [each.key] + + # Ensure proper dependency ordering to avoid provider bugs + depends_on = [juju_application.dellsc_backends] + + lifecycle { + # Prevent destruction when applications list becomes empty + prevent_destroy = false + # Create before destroy to avoid empty applications list state + create_before_destroy = true + } +} + +resource "juju_access_secret" "dellsc_secondary_san_credentials_access" { + for_each = { + for backend_name, backend_config in var.dellsc_backends : + backend_name => backend_config + if backend_config.secondary_san_username != "" && backend_config.secondary_san_password != "" + } + + model = data.juju_model.machine_model.name + secret_id = juju_secret.dellsc_secondary_san_credentials[each.key].secret_id + applications = [each.key] + + # Ensure proper dependency ordering to avoid provider bugs + depends_on = [juju_application.dellsc_backends] + + lifecycle { + # Prevent destruction when applications list becomes empty + prevent_destroy = false + # Create before destroy to avoid empty applications list state + create_before_destroy = true + } +} + +# Deploy Dell Storage Center backend charms +resource "juju_application" "dellsc_backends" { + for_each = var.dellsc_backends + + name = each.key + model = data.juju_model.machine_model.name + units = 1 + + charm { + name = var.charm_dellsc_name + channel = var.charm_dellsc_channel + revision = var.charm_dellsc_revision + base = var.charm_dellsc_base + } + + config = merge({ + volume-backend-name = each.key + }, each.value.charm_config, { + # Main array credentials - always required + san-credentials-secret = contains(keys(juju_secret.dellsc_san_credentials), each.key) ? juju_secret.dellsc_san_credentials[each.key].secret_id : "" + + # Secondary array credentials - only if dual DSM is configured + secondary-san-credentials-secret = contains(keys(juju_secret.dellsc_secondary_san_credentials), each.key) ? juju_secret.dellsc_secondary_san_credentials[each.key].secret_id : "" + }) + + endpoint_bindings = var.endpoint_bindings +} + +# Integrate Dell Storage Center backends with main cinder-volume +resource "juju_integration" "dellsc_to_cinder_volume" { + for_each = var.dellsc_backends + + model = var.machine_model + + application { + name = juju_application.dellsc_backends[each.key].name + endpoint = var.charm_dellsc_endpoint + } + + application { + name = "cinder-volume" + endpoint = var.charm_dellsc_endpoint + } +} diff --git a/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/outputs.tf b/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/outputs.tf new file mode 100644 index 000000000..295810f44 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/outputs.tf @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +output "dellsc_backend_applications" { + description = "Map of deployed Dell Storage Center backend applications" + value = { + for name, app in juju_application.dellsc_backends : name => { + name = app.name + model = app.model + units = app.units + } + } +} diff --git a/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/test-variables.tfvars b/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/test-variables.tfvars new file mode 100644 index 000000000..7464d4c48 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/test-variables.tfvars @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +# Test variables for Dell Storage Center backend deployment + +machine_model = "openstack" + +charm_dellsc_name = "cinder-volume-dellsc" +charm_dellsc_base = "ubuntu@24.04" +charm_dellsc_channel = "latest/edge" +charm_dellsc_endpoint = "cinder-volume" +charm_dellsc_revision = 1 + +dellsc_backends = { + "dellsc-test" = { + charm_config = { + san-ip = "192.168.1.100" + dell-sc-ssn = "64702" + protocol = "fc" + dell-sc-api-port = "3033" + dell-sc-server-folder = "openstack" + dell-sc-volume-folder = "openstack" + dell-server-os = "Red Hat Linux 6.x" + dell-sc-verify-cert = "false" + san-thin-provision = "true" + dell-api-async-rest-timeout = "15" + dell-api-sync-rest-timeout = "30" + ssh-conn-timeout = "30" + ssh-max-pool-conn = "5" + ssh-min-pool-conn = "1" + } + + # Main array credentials + san_username = "admin" + san_password = "password123" + + # Secondary array credentials (optional, for dual DSM) + secondary_san_username = "" + secondary_san_password = "" + } +} diff --git a/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/variables.tf b/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/variables.tf new file mode 100644 index 000000000..1ec1ebcd0 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/variables.tf @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +variable "machine_model" { + description = "Name of the machine model to deploy to" + type = string +} + +variable "charm_dellsc_name" { + description = "Name of the Dell Storage Center charm" + type = string + default = "cinder-volume-dellsc" +} + +variable "charm_dellsc_base" { + description = "Base for the Dell Storage Center charm" + type = string + default = "ubuntu@24.04" +} + +variable "charm_dellsc_channel" { + description = "Operator channel for Dell Storage Center backend deployment" + type = string + default = "latest/edge" +} + +variable "charm_dellsc_endpoint" { + description = "Endpoint name for Dell Storage Center backend integration" + type = string + default = "cinder-volume" +} + +variable "charm_dellsc_revision" { + description = "Operator channel revision for Dell Storage Center backend deployment" + type = number + default = null +} + +variable "dellsc_backends" { + description = "Map of Dell Storage Center backend configurations" + type = map(object({ + charm_config = map(string) + + # Main array credentials (required) + san_username = string + san_password = string + + # Secondary array credentials (optional, for dual DSM) + secondary_san_username = optional(string, "") + secondary_san_password = optional(string, "") + })) + default = {} +} + +variable "machine_ids" { + description = "List of machine ids to include" + type = list(string) + default = [] +} + +variable "endpoint_bindings" { + description = "Endpoint bindings for the applications" + type = set(map(string)) + default = null +} diff --git a/sunbeam-python/sunbeam/storage/backends/hitachi/backend.py b/sunbeam-python/sunbeam/storage/backends/hitachi/backend.py new file mode 100644 index 000000000..ef3875988 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/hitachi/backend.py @@ -0,0 +1,608 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Hitachi VSP storage backend implementation using base step classes.""" + +import logging +from typing import Any, Dict, Literal, Set + +import click + +try: + import yaml as _yaml # type: ignore + + yaml: Any = _yaml +except Exception: # yaml optional; handle gracefully at runtime + yaml = None + +# Import pydantic Field directly +from pydantic import Field +from rich.console import Console + +from sunbeam.core.common import BaseStep +from sunbeam.core.deployment import Deployment +from sunbeam.core.juju import JujuHelper +from sunbeam.core.manifest import Manifest +from sunbeam.core.terraform import TerraformHelper +from sunbeam.storage.base import StorageBackendBase +from sunbeam.storage.models import StorageBackendConfig +from sunbeam.storage.steps import ( + BaseStorageBackendConfigUpdateStep, + BaseStorageBackendDeployStep, + BaseStorageBackendDestroyStep, +) + +LOG = logging.getLogger(__name__) +console = Console() + + +class HitachiConfig(StorageBackendConfig): + """Static configuration model for Hitachi VSP storage backend. + + This model includes all configuration options supported by the + cinder-volume-hitachi charm as defined in charmcraft.yaml. + """ + + # Required fields (inherited from StorageBackendConfig) + # name: str (from base class) + + # Mandatory connection parameters + hitachi_storage_id: str = Field( + ..., description="Storage system product number/serial" + ) + hitachi_pools: str = Field( + ..., description="Comma-separated list of DP pool names/IDs" + ) + san_ip: str = Field(..., description="Hitachi VSP management IP or hostname") + san_username: str = Field(..., description="SAN management username") + san_password: str = Field(..., description="SAN management password") + protocol: Literal["FC", "iSCSI"] = Field( + ..., description="Front-end protocol (FC or iSCSI)" + ) + + # Backend configuration + volume_backend_name: str = Field( + default="", description="Name that Cinder will report for this backend" + ) + backend_availability_zone: str = Field( + default="", description="Availability zone to associate with this backend" + ) + + # Optional host-group / zoning controls + hitachi_target_ports: str = Field( + default="", description="Comma-separated front-end port labels" + ) + hitachi_compute_target_ports: str = Field( + default="", description="Comma-separated compute-node port IDs" + ) + hitachi_ldev_range: str = Field( + default="", description="LDEV range usable by the driver" + ) + hitachi_zoning_request: bool = Field( + default=False, description="Request FC zone-manager to create zoning" + ) + + # Copy & replication tuning + hitachi_copy_speed: int = Field( + default=3, description="Copy bandwidth throttle (1-15)" + ) + hitachi_copy_check_interval: int = Field( + default=3, description="Seconds between sync copy-status polls" + ) + hitachi_async_copy_check_interval: int = Field( + default=10, description="Seconds between async copy-status polls" + ) + + # iSCSI authentication + use_chap_auth: bool = Field( + default=False, description="Use CHAP authentication for iSCSI" + ) + + # Array ranges and controls + hitachi_discard_zero_page: bool = Field( + default=True, description="Enable zero-page reclamation in DP-VOLs" + ) + hitachi_exec_retry_interval: int = Field( + default=5, description="Seconds to wait before retrying REST API call" + ) + hitachi_extend_timeout: int = Field( + default=600, description="Max seconds to wait for volume extension" + ) + hitachi_group_create: bool = Field( + default=False, description="Automatically create host groups or iSCSI targets" + ) + hitachi_group_delete: bool = Field( + default=False, description="Automatically delete unused host groups" + ) + hitachi_group_name_format: str = Field( + default="", description="Python format string for naming host groups" + ) + hitachi_host_mode_options: str = Field( + default="", description="Comma-separated host mode options" + ) + hitachi_lock_timeout: int = Field( + default=7200, description="Max seconds for array login/unlock operations" + ) + hitachi_lun_retry_interval: int = Field( + default=1, description="Seconds before retrying LUN mapping" + ) + hitachi_lun_timeout: int = Field( + default=50, description="Max seconds to wait for LUN mapping" + ) + hitachi_port_scheduler: bool = Field( + default=False, description="Enable round-robin WWN registration" + ) + + # Mirror/replication settings + hitachi_mirror_compute_target_ports: str = Field( + default="", description="Compute-node port names for GAD" + ) + hitachi_mirror_ldev_range: str = Field( + default="", description="LDEV range for secondary storage" + ) + hitachi_mirror_pair_target_number: int = Field( + default=0, description="Host group number for GAD on secondary" + ) + hitachi_mirror_pool: str = Field( + default="", description="DP pool name/ID on secondary storage" + ) + hitachi_mirror_rest_api_ip: str = Field( + default="", description="REST API IP on secondary storage" + ) + hitachi_mirror_rest_api_port: int = Field( + default=443, description="REST API port on secondary storage" + ) + hitachi_mirror_rest_pair_target_ports: str = Field( + default="", description="Pair-target port names for GAD" + ) + hitachi_mirror_snap_pool: str = Field( + default="", description="Snapshot pool on secondary storage" + ) + hitachi_mirror_ssl_cert_path: str = Field( + default="", description="CA_BUNDLE for secondary REST endpoint" + ) + hitachi_mirror_ssl_cert_verify: bool = Field( + default=False, description="Validate SSL cert of secondary REST" + ) + hitachi_mirror_storage_id: str = Field( + default="", description="Product number of secondary storage" + ) + hitachi_mirror_target_ports: str = Field( + default="", description="Controller node port IDs for GAD" + ) + hitachi_mirror_use_chap_auth: bool = Field( + default=False, description="Use CHAP auth for GAD on secondary" + ) + + # Replication settings + hitachi_pair_target_number: int = Field( + default=0, description="Host group number for primary replication" + ) + hitachi_path_group_id: int = Field( + default=0, description="Path group ID for remote replication" + ) + hitachi_quorum_disk_id: int = Field( + default=0, description="Quorum disk ID for Global-Active Device" + ) + hitachi_replication_copy_speed: int = Field( + default=3, description="Copy speed for remote replication" + ) + hitachi_replication_number: int = Field( + default=0, description="Instance number for REST API on replication" + ) + hitachi_replication_status_check_long_interval: int = Field( + default=600, description="Poll interval after initial check" + ) + hitachi_replication_status_check_short_interval: int = Field( + default=5, description="Initial poll interval" + ) + hitachi_replication_status_check_timeout: int = Field( + default=86400, description="Max seconds for status change" + ) + + # REST API settings + hitachi_rest_another_ldev_mapped_retry_timeout: int = Field( + default=600, description="Retry seconds when LDEV allocation fails" + ) + hitachi_rest_connect_timeout: int = Field( + default=30, description="Max seconds to establish REST connection" + ) + hitachi_rest_disable_io_wait: bool = Field( + default=True, description="Detach volumes without waiting for I/O drain" + ) + hitachi_rest_get_api_response_timeout: int = Field( + default=1800, description="Max seconds for sync REST GET" + ) + hitachi_rest_job_api_response_timeout: int = Field( + default=1800, description="Max seconds for async REST PUT/DELETE" + ) + hitachi_rest_keep_session_loop_interval: int = Field( + default=180, description="Seconds between keep-alive loops" + ) + hitachi_rest_pair_target_ports: str = Field( + default="", description="Pair-target port names for REST operations" + ) + hitachi_rest_server_busy_timeout: int = Field( + default=7200, description="Max seconds when REST API returns busy" + ) + hitachi_rest_tcp_keepalive: bool = Field( + default=True, description="Enable TCP keepalive for REST connections" + ) + hitachi_rest_tcp_keepcnt: int = Field( + default=4, description="Number of TCP keepalive probes" + ) + hitachi_rest_tcp_keepidle: int = Field( + default=60, description="Seconds before sending first TCP keepalive" + ) + hitachi_rest_tcp_keepintvl: int = Field( + default=15, description="Seconds between TCP keepalive probes" + ) + hitachi_rest_timeout: int = Field( + default=30, description="Max seconds for each REST API call" + ) + hitachi_restore_timeout: int = Field( + default=86400, description="Max seconds to wait for restore operation" + ) + + # Snapshot settings + hitachi_snap_pool: str = Field(default="", description="Pool name/ID for snapshots") + hitachi_state_transition_timeout: int = Field( + default=900, description="Max seconds for volume state transition" + ) + + # Juju secrets for credentials (not charm config options) + san_credentials_secret: str = Field( + default="", description="Juju secret URI for SAN credentials" + ) + chap_credentials_secret: str = Field( + default="", description="Juju secret URI for CHAP credentials" + ) + hitachi_mirror_chap_credentials_secret: str = Field( + default="", description="Juju secret URI for mirror CHAP credentials" + ) + hitachi_mirror_rest_credentials_secret: str = Field( + default="", description="Juju secret URI for mirror REST credentials" + ) + + chap_username: str = Field( + default="", description="CHAP username for secret creation" + ) + chap_password: str = Field( + default="", description="CHAP password for secret creation" + ) + hitachi_mirror_chap_username: str = Field( + default="", description="Mirror CHAP username for secret creation" + ) + hitachi_mirror_chap_password: str = Field( + default="", description="Mirror CHAP password for secret creation" + ) + hitachi_mirror_rest_username: str = Field( + default="", description="Mirror REST username for secret creation" + ) + hitachi_mirror_rest_password: str = Field( + default="", description="Mirror REST password for secret creation" + ) + + +class HitachiBackend(StorageBackendBase): + """Hitachi storage backend implementation.""" + + name = "hitachi" + display_name = "Hitachi VSP Storage" + charm_name = "cinder-volume-hitachi" + + def __init__(self): + """Initialize Hitachi backend.""" + super().__init__() + self.tfplan = "hitachi-backend-plan" + self.tfplan_dir = "deploy-hitachi-backend" + + charm_channel = ( + "latest/edge" # Use edge for development, change to stable for production + ) + charm_revision = 2 + charm_base = "ubuntu@24.04" # Updated to match uploaded charm base + backend_endpoint = "cinder-volume" + units = 1 + additional_integrations = [] + + @property + def config_class(self) -> type[StorageBackendConfig]: + """Return the configuration class for Hitachi backend.""" + return HitachiConfig + + def _get_credential_fields(self) -> Set[str]: + """Get set of credential field names that should be excluded from charm config. + + For Hitachi backend, we exclude all credential fields and secret URIs. + """ + return { + # meta + "name", + # primary array credentials + "san_username", + "san_password", + # CHAP credentials + "chap_username", + "chap_password", + # mirror CHAP credentials + "hitachi_mirror_chap_username", + "hitachi_mirror_chap_password", + # mirror REST credentials + "hitachi_mirror_rest_username", + "hitachi_mirror_rest_password", + # juju secret URIs + "san_credentials_secret", + "chap_credentials_secret", + "hitachi_mirror_chap_credentials_secret", + "hitachi_mirror_rest_credentials_secret", + } + + def get_field_mapping(self) -> Dict[str, str]: + """Get mapping from config fields to charm config options. + + Uses base class automatic mapping and excludes credential fields. + """ + # Start from the base automatic mapping + mapping = super().get_field_mapping() + + # Exclude credential fields + exclude = self._get_credential_fields() + return {k: v for k, v in mapping.items() if k not in exclude} + + # CLI registration uses base class implementation + + def get_terraform_variables( + self, backend_name: str, config: StorageBackendConfig, model: str + ) -> Dict[str, Any]: + """Generate Terraform variables for Hitachi backend deployment.""" + # Map our configuration fields to the correct charm configuration option names + config_dict = config.model_dump() + field_mapping = self.get_field_mapping() + + # Filter config using base class method, excluding credential fields + charm_config = self._filter_config_for_charm( + config_dict, field_mapping, exclude_fields=self._get_credential_fields() + ) + + # Build Terraform variables to match the plan's expected format + tfvars = { + "machine_model": model, + "charm_hitachi_name": self.charm_name, + "charm_hitachi_base": self.charm_base, + "charm_hitachi_channel": self.charm_channel, + "charm_hitachi_endpoint": self.backend_endpoint, + "charm_hitachi_revision": self.charm_revision, + "hitachi_backends": { + backend_name: { + "charm_config": charm_config, + # Main array credentials (always required) + "san_username": config_dict.get("san_username", ""), + "san_password": config_dict.get("san_password", ""), + # CHAP credentials (optional) + "use_chap_auth": config_dict.get("use_chap_auth", False), + "chap_username": config_dict.get("chap_username", ""), + "chap_password": config_dict.get("chap_password", ""), + # Mirror CHAP credentials (optional) + "hitachi_mirror_chap_username": config_dict.get( + "hitachi_mirror_chap_username", "" + ), + "hitachi_mirror_chap_password": config_dict.get( + "hitachi_mirror_chap_password", "" + ), + # Mirror REST API credentials (optional) + "hitachi_mirror_rest_username": config_dict.get( + "hitachi_mirror_rest_username", "" + ), + "hitachi_mirror_rest_password": config_dict.get( + "hitachi_mirror_rest_password", "" + ), + } + }, + } + + return tfvars + + def _get_default_config(self) -> HitachiConfig: + """Get a default configuration instance for comparison.""" + return HitachiConfig( + name="dummy", + hitachi_storage_id="dummy", + hitachi_pools="dummy", + san_ip="dummy", + san_username="dummy", + san_password="dummy", # noqa: S106 + protocol="FC", + ) + + def prompt_for_config(self, backend_name: str) -> HitachiConfig: + """Prompt user for Hitachi-specific configuration.""" + return self._prompt_for_config(backend_name) + + # IP/FQDN validation uses base class implementation + + def _prompt_for_config(self, backend_name: str) -> HitachiConfig: + """Prompt user for Hitachi backend configuration.""" + console.print( + "\n[bold blue]Hitachi VSP Storage Backend Configuration[/bold blue]" + ) + console.print("Please provide the required configuration options:") + + # Prompt for required fields + hitachi_storage_id = click.prompt("Array serial number", type=str) + hitachi_pools = click.prompt("Storage pools (comma separated)", type=str) + protocol = click.prompt( + "Protocol", + type=click.Choice(["FC", "iSCSI"], case_sensitive=False), + default="FC", + ) + san_ip = click.prompt( + "Management IP/FQDN", type=str, value_proc=self._validate_ip_or_fqdn + ) + + # Main array credentials (will be automatically converted to Juju secret) + console.print("\n[bold yellow]Array Credentials[/bold yellow]") + console.print( + "These credentials will be automatically stored in a Juju secret." + ) + san_username = click.prompt("SAN username", type=str, default="maintenance") + san_password = click.prompt("SAN password", type=str, hide_input=True) + + # Optional: prompt for volume backend name (defaults to backend name) + volume_backend_name = click.prompt( + "Volume backend name", type=str, default=backend_name, show_default=True + ) + + # Optional: CHAP authentication for iSCSI + chap_username = "" + chap_password = "" + use_chap_auth = False + if protocol.lower() == "iscsi": + use_chap_auth = click.confirm( + "\nUse CHAP authentication for iSCSI?", default=False + ) + if use_chap_auth: + console.print("[bold yellow]CHAP Credentials[/bold yellow]") + console.print( + "These credentials will be automatically stored in a Juju secret." + ) + chap_username = click.prompt("CHAP username", type=str) + chap_password = click.prompt("CHAP password", type=str, hide_input=True) + + # Optional: Mirror/GAD configuration + hitachi_mirror_chap_username = "" + hitachi_mirror_chap_password = "" + hitachi_mirror_rest_username = "" + hitachi_mirror_rest_password = "" + + configure_mirror = click.confirm( + "\nConfigure mirror/replication (GAD) settings?", default=False + ) + if configure_mirror: + console.print( + "\n[bold yellow]Mirror/Replication Configuration[/bold yellow]" + ) + + # Mirror CHAP credentials + if click.confirm("Configure mirror CHAP credentials?", default=False): + console.print("[bold yellow]Mirror CHAP Credentials[/bold yellow]") + console.print( + "These credentials will be automatically stored in a Juju secret." + ) + hitachi_mirror_chap_username = click.prompt( + "Mirror CHAP username", type=str + ) + hitachi_mirror_chap_password = click.prompt( + "Mirror CHAP password", type=str, hide_input=True + ) + + # Mirror REST API credentials + if click.confirm("Configure mirror REST API credentials?", default=False): + console.print("[bold yellow]Mirror REST API Credentials[/bold yellow]") + console.print( + "These credentials will be automatically stored in a Juju secret." + ) + hitachi_mirror_rest_username = click.prompt( + "Mirror REST API username", type=str + ) + hitachi_mirror_rest_password = click.prompt( + "Mirror REST API password", type=str, hide_input=True + ) + + return HitachiConfig( + name=backend_name, + hitachi_storage_id=hitachi_storage_id, + hitachi_pools=hitachi_pools, + protocol=protocol, + san_ip=san_ip, + san_username=san_username, + san_password=san_password, + volume_backend_name=volume_backend_name, + use_chap_auth=use_chap_auth, + chap_username=chap_username, + chap_password=chap_password, + hitachi_mirror_chap_username=hitachi_mirror_chap_username, + hitachi_mirror_chap_password=hitachi_mirror_chap_password, + hitachi_mirror_rest_username=hitachi_mirror_rest_username, + hitachi_mirror_rest_password=hitachi_mirror_rest_password, + ) + + # Implementation of abstract methods from StorageBackendBase + def create_deploy_step( + self, + deployment: Deployment, + client, + tfhelper: TerraformHelper, + jhelper: JujuHelper, + manifest: Manifest, + backend_name: str, + backend_config: StorageBackendConfig, + model: str, + ) -> BaseStep: + """Create a deployment step for Hitachi backend.""" + return HitachiDeployStep( + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + backend_config, + self, + model, + ) + + def create_destroy_step( + self, + deployment: Deployment, + client, + tfhelper: TerraformHelper, + jhelper: JujuHelper, + manifest: Manifest, + backend_name: str, + model: str, + ) -> BaseStep: + """Create a destruction step for Hitachi backend.""" + return HitachiDestroyStep( + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + self, + model, + ) + + def create_update_config_step( + self, + deployment: Deployment, + backend_name: str, + config_updates: Dict[str, Any], + ) -> BaseStep: + """Create a configuration update step for Hitachi backend.""" + return HitachiUpdateConfigStep( + deployment, + self, + backend_name, + config_updates, + ) + + +# Hitachi-specific step implementations using base step classes +class HitachiDeployStep(BaseStorageBackendDeployStep): + """Deploy Hitachi storage backend using base step class.""" + + def get_terraform_variables(self) -> Dict[str, Any]: + """Get Terraform variables for Hitachi backend deployment.""" + return self.backend_instance.get_terraform_variables( + self.backend_name, self.backend_config, self.model + ) + + +class HitachiDestroyStep(BaseStorageBackendDestroyStep): + """Destroy Hitachi storage backend using base step class.""" + + +class HitachiUpdateConfigStep(BaseStorageBackendConfigUpdateStep): + """Update Hitachi storage backend configuration using base step class.""" diff --git a/sunbeam-python/sunbeam/storage/backends/hitachi/cli.py b/sunbeam-python/sunbeam/storage/backends/hitachi/cli.py new file mode 100644 index 000000000..e06effd76 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/hitachi/cli.py @@ -0,0 +1,344 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""CLI functionality for Hitachi storage backend. + +This module contains all CLI-related code moved from backend.py, +including command registration and helper functions. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, Optional + +import click +import pydantic +from rich.console import Console + +try: + import yaml as _yaml # type: ignore + + yaml: Any = _yaml +except Exception: # yaml optional; handle gracefully at runtime + yaml = None + +from sunbeam.core.deployment import Deployment +from sunbeam.storage.backends.hitachi.backend import HitachiBackend +from sunbeam.storage.service import StorageBackendService + +console = Console() + + +class HitachiCLI: + """CLI functionality for Hitachi storage backend.""" + + def __init__(self, backend: HitachiBackend): + self.backend = backend + + def _load_config_file(self, path: Optional[Path]) -> Dict[str, Any]: + """Load YAML or JSON config file into a dictionary. + + YAML is preferred if PyYAML is available, otherwise JSON is used. + """ + if not path: + return {} + text = path.read_text() + if yaml is not None: + return dict(yaml.safe_load(text) or {}) + + return dict(json.loads(text)) + + def register_add_cli(self, add: click.Group) -> None: # noqa: C901 + """Register 'sunbeam storage add hitachi'. + + Includes typed options and a --config-file flag. + """ + + def _click_type_for(field_info) -> click.types.ParamType: + # Map pydantic field to Click type + ann = getattr(field_info, "annotation", None) + typ = None + if ann is not None: + typ = str(ann) + elif hasattr(field_info, "type_"): + typ = str(field_info.type_) + if typ and ("int" in typ): + return click.INT + if typ and ("float" in typ): + return click.FLOAT + if typ and ("bool" in typ): + return click.BOOL + return click.STRING + + def _build_params(required_all: bool) -> list: + params: list = [] + # name option (not required; prompt in interactive mode) + params.append( + click.Option(["--name"], type=str, required=False, help="Backend name") + ) + # config file (optional) + params.append( + click.Option( + ["--config-file"], + type=click.Path(exists=True, dir_okay=False, path_type=Path), + required=False, + help="YAML/JSON config file", + ) + ) + # Model-derived options + fields = getattr(self.backend.config_class, "model_fields", {}) + for fname, finfo in fields.items(): + if fname == "name": + continue + opt_name = "--" + fname.replace("_", "-") + click_type = _click_type_for(finfo) + # Determine requiredness for add (respect model) + # For interactive UX, keep CLI options optional; the model + # enforces requiredness. + is_required = False + # Help text + descr = None + if hasattr(finfo, "field_info") and hasattr( + finfo.field_info, "description" + ): + descr = finfo.field_info.description + elif hasattr(finfo, "description"): + descr = finfo.description + params.append( + click.Option( + [opt_name], type=click_type, required=is_required, help=descr + ) + ) + return params + + def _build_config_from_kwargs(kwargs: Dict[str, Any]) -> Dict[str, Any]: + # Extract name and field values from kwargs + # (Click converts dashes to underscores) + cfg: Dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + cfg[k] = v + return cfg + + def add_callback(**kwargs): + deployment: Deployment = click.get_current_context().obj + cfg_file = kwargs.pop("config_file", None) + file_cfg = self._load_config_file(cfg_file) + cli_cfg = _build_config_from_kwargs(kwargs) + # Determine if interactive: no config-file and no CLI options supplied + provided_cli_values = {k: v for k, v in cli_cfg.items() if v is not None} + interactive = not file_cfg and not provided_cli_values + + if interactive: + # Prompt for name and full config via helper + console.print( + f"[blue]Setting up {self.backend.display_name} backend[/blue]" + ) + backend_name = click.prompt("Backend name", type=str) + config_instance = self.backend.prompt_for_config(backend_name) + config_instance.name = backend_name + self.backend.add_backend( + deployment, backend_name, config_instance, console + ) + return + + # Non-interactive path: merge file and CLI, validate + merged = {**file_cfg, **provided_cli_values} + try: + config_instance = self.backend.config_class(**merged) + except pydantic.ValidationError as e: + console.print("[red]Configuration validation error:[/red]") + for error in e.errors(): + field_name = error.get("loc", ["unknown"])[0] + # Convert field name to CLI parameter format + cli_param = f"--{field_name.replace('_', '-')}" + console.print(f" {cli_param}: {error['msg']}") + raise click.Abort() + backend_name = merged.get("name") + if not backend_name: + raise click.BadParameter( + "--name is required when not running interactively" + ) + self.backend.add_backend(deployment, backend_name, config_instance, console) + + # Build command dynamically with parameters + params = _build_params(required_all=False) + help_text = ( + f"Add {self.backend.display_name} backend.\n\n" + "Behavior:\n" + "- If no options are provided, runs in interactive mode and prompts " + "for all required fields.\n" + "- If options and/or --config-file are provided, runs non-" + "interactively and validates against the model.\n" + "- In non-interactive mode, --name is required (or supplied via " + "--config-file).\n\n" + "Examples:\n" + " sunbeam storage add hitachi\n" + " sunbeam storage add hitachi --config-file hitachi.yaml\n" + " sunbeam storage add hitachi --name myhitachi --san-ip 10.0.0.10 " + "--protocol FC\n" + ) + cmd = click.Command( + name=self.backend.name, + params=params, + callback=add_callback, + help=help_text, + ) + add.add_command(cmd) + + def register_cli( # noqa: C901 + self, + remove: click.Group, + config_show: click.Group, + config_set: click.Group, + config_options: click.Group, + deployment: Deployment, + ) -> None: + """Register management commands for Hitachi backend.""" + + @click.command(name=self.backend.name) + @click.argument("backend_name", type=str) + @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") + @click.pass_context + def remove_hitachi(ctx, backend_name: str, yes: bool): + service = self.backend._get_service(deployment) + if not service.backend_exists(backend_name, self.backend.name): + console.print(f"[red]Error: Backend '{backend_name}' not found[/red]") + raise click.Abort() + if not yes: + click.confirm( + f"Remove {self.backend.display_name} backend '{backend_name}'?", + abort=True, + ) + try: + self.backend.remove_backend(deployment, backend_name, console) + except Exception as e: + console.print(f"[red]Error removing backend: {e}[/red]") + raise click.Abort() + + remove.add_command(remove_hitachi) + + @click.command(name=self.backend.name) + @click.argument("backend_name", type=str) + @click.pass_context + def config_show_hitachi(ctx, backend_name: str): + service = self.backend._get_service(deployment) + config = service.get_backend_config(backend_name, self.backend.name) + self.backend.display_config_table(backend_name, config) + + config_show.add_command(config_show_hitachi) + + # Build typed options for config set (only provided options are updated) + def _build_set_params() -> list: + params = [] + # Add config-file option first + params.append( + click.Option( + ["--config-file"], + type=click.Path( + exists=True, dir_okay=False, readable=True, path_type=Path + ), + required=False, + help="YAML/JSON config file with updates", + ) + ) + fields = getattr(self.backend.config_class, "model_fields", {}) + for fname, finfo in fields.items(): + if fname == "name": + continue + opt = "--" + fname.replace("_", "-") + # For updates, make everything optional with default None + # so we can detect presence + click_type: click.ParamType = click.STRING + ann = getattr(finfo, "annotation", None) + if ann is not None and ("bool" in str(ann)): + click_type = click.BOOL + elif ann is not None and ("int" in str(ann)): + click_type = click.INT + elif ann is not None and ("float" in str(ann)): + click_type = click.FLOAT + descr = None + if hasattr(finfo, "field_info") and hasattr( + finfo.field_info, "description" + ): + descr = finfo.field_info.description + elif hasattr(finfo, "description"): + descr = finfo.description + params.append( + click.Option( + [opt], type=click_type, required=False, default=None, help=descr + ) + ) + return params + + def set_callback(backend_name: str, **kwargs): + cfg_file = kwargs.pop("config_file", None) + file_cfg = {} + if cfg_file: + file_cfg = self._load_config_file(cfg_file) + # Only include keys that were explicitly provided (value not None) + updates = {k: v for k, v in kwargs.items() if v is not None} + updates = {**file_cfg, **updates} + try: + # Get current configuration and merge with updates for validation + service = StorageBackendService(deployment) + current_config = service.get_backend_config( + backend_name, self.backend.name + ) + + # Create a merged config for validation + # Current config comes from charm (kebab-case keys), + # convert to snake_case + merged_config = {} + for key, value in current_config.items(): + snake_key = key.replace("-", "_") + merged_config[snake_key] = value + + # Updates from CLI are already in snake_case + # (Click converts --protocol to protocol) + merged_config.update(updates) + merged_config["name"] = backend_name # Ensure name is set + _ = self.backend.config_class(**merged_config) + except pydantic.ValidationError as e: + console.print("[red]Configuration validation error:[/red]") + for error in e.errors(): + field_name = error["loc"][0] if error.get("loc") else "unknown" + # Convert field name to CLI parameter format + cli_param = f"--{str(field_name).replace('_', '-')}" + console.print(f" {cli_param}: {error['msg']}") + raise click.ClickException( + "Configuration update failed due to validation error" + ) + try: + self.backend.update_backend_config(deployment, backend_name, updates) + console.print( + ( + f"[green]Configuration updated for {self.backend.display_name} " + f"backend '{backend_name}'[/green]" + ) + ) + except Exception as e: + console.print(f"[red]Failed to update configuration: {e}[/red]") + raise click.ClickException(f"Configuration update failed: {e}") + + set_params = _build_set_params() + # Add backend_name as a required argument + set_params.insert(0, click.Argument(["backend_name"], type=str, required=True)) + set_cmd = click.Command( + name=self.backend.name, + params=set_params, + callback=set_callback, + help="Set configuration options", + ) + config_set.add_command(set_cmd) + + @click.command(name=self.backend.name) + @click.argument("backend_name", type=str, required=False) + @click.pass_context + def config_options_hitachi(ctx, backend_name: str | None = None): + self.backend.display_config_options() + + config_options.add_command(config_options_hitachi) diff --git a/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/main.tf b/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/main.tf new file mode 100644 index 000000000..ee0e566d3 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/main.tf @@ -0,0 +1,222 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +terraform { + required_providers { + juju = { + source = "juju/juju" + version = "= 0.20.0" + } + } +} + +provider "juju" {} + +data "juju_model" "machine_model" { + name = var.machine_model +} + +# Create Juju secrets for Hitachi backend credentials +# Main array credentials (required for all backends) +resource "juju_secret" "hitachi_san_credentials" { + for_each = { + for backend_name, backend_config in var.hitachi_backends : + backend_name => backend_config + if backend_config.san_username != "" && backend_config.san_password != "" + } + + model = data.juju_model.machine_model.name + name = "${each.key}-san-credentials" + value = { + username = each.value.san_username + password = each.value.san_password + } +} + +# CHAP credentials (optional, only for iSCSI with CHAP auth) +resource "juju_secret" "hitachi_chap_credentials" { + for_each = { + for backend_name, backend_config in var.hitachi_backends : + backend_name => backend_config + if backend_config.use_chap_auth == true && backend_config.chap_username != "" && backend_config.chap_password != "" + } + + model = data.juju_model.machine_model.name + name = "${each.key}-chap-credentials" + value = { + username = each.value.chap_username + password = each.value.chap_password + } +} + +# Mirror CHAP credentials (optional, for GAD replication) +resource "juju_secret" "hitachi_mirror_chap_credentials" { + for_each = { + for backend_name, backend_config in var.hitachi_backends : + backend_name => backend_config + if backend_config.hitachi_mirror_chap_username != "" && backend_config.hitachi_mirror_chap_password != "" + } + + model = data.juju_model.machine_model.name + name = "${each.key}-mirror-chap-credentials" + value = { + username = each.value.hitachi_mirror_chap_username + password = each.value.hitachi_mirror_chap_password + } +} + +# Mirror REST API credentials (optional, for GAD replication) +resource "juju_secret" "hitachi_mirror_rest_credentials" { + for_each = { + for backend_name, backend_config in var.hitachi_backends : + backend_name => backend_config + if backend_config.hitachi_mirror_rest_username != "" && backend_config.hitachi_mirror_rest_password != "" + } + + model = data.juju_model.machine_model.name + name = "${each.key}-mirror-rest-credentials" + value = { + username = each.value.hitachi_mirror_rest_username + password = each.value.hitachi_mirror_rest_password + } +} + +# Grant access to secrets for Hitachi backend applications +resource "juju_access_secret" "hitachi_san_credentials_access" { + for_each = { + for backend_name, backend_config in var.hitachi_backends : + backend_name => backend_config + if backend_config.san_username != "" && backend_config.san_password != "" + } + + model = data.juju_model.machine_model.name + secret_id = juju_secret.hitachi_san_credentials[each.key].secret_id + applications = [each.key] + + # Ensure proper dependency ordering to avoid provider bugs + depends_on = [juju_application.hitachi_backends] + + lifecycle { + # Prevent destruction when applications list becomes empty + prevent_destroy = false + # Create before destroy to avoid empty applications list state + create_before_destroy = true + } +} + +resource "juju_access_secret" "hitachi_chap_credentials_access" { + for_each = { + for backend_name, backend_config in var.hitachi_backends : + backend_name => backend_config + if backend_config.use_chap_auth == true && backend_config.chap_username != "" && backend_config.chap_password != "" + } + + model = data.juju_model.machine_model.name + secret_id = juju_secret.hitachi_chap_credentials[each.key].secret_id + applications = [each.key] + + # Ensure proper dependency ordering to avoid provider bugs + depends_on = [juju_application.hitachi_backends] + + lifecycle { + # Prevent destruction when applications list becomes empty + prevent_destroy = false + # Create before destroy to avoid empty applications list state + create_before_destroy = true + } +} + +resource "juju_access_secret" "hitachi_mirror_chap_credentials_access" { + for_each = { + for backend_name, backend_config in var.hitachi_backends : + backend_name => backend_config + if backend_config.hitachi_mirror_chap_username != "" && backend_config.hitachi_mirror_chap_password != "" + } + + model = data.juju_model.machine_model.name + secret_id = juju_secret.hitachi_mirror_chap_credentials[each.key].secret_id + applications = [each.key] + + # Ensure proper dependency ordering to avoid provider bugs + depends_on = [juju_application.hitachi_backends] + + lifecycle { + # Prevent destruction when applications list becomes empty + prevent_destroy = false + # Create before destroy to avoid empty applications list state + create_before_destroy = true + } +} + +resource "juju_access_secret" "hitachi_mirror_rest_credentials_access" { + for_each = { + for backend_name, backend_config in var.hitachi_backends : + backend_name => backend_config + if backend_config.hitachi_mirror_rest_username != "" && backend_config.hitachi_mirror_rest_password != "" + } + + model = data.juju_model.machine_model.name + secret_id = juju_secret.hitachi_mirror_rest_credentials[each.key].secret_id + applications = [each.key] + + # Ensure proper dependency ordering to avoid provider bugs + depends_on = [juju_application.hitachi_backends] + + lifecycle { + # Prevent destruction when applications list becomes empty + prevent_destroy = false + # Create before destroy to avoid empty applications list state + create_before_destroy = true + } +} + +# Deploy Hitachi storage backend charms +resource "juju_application" "hitachi_backends" { + for_each = var.hitachi_backends + + name = each.key + model = data.juju_model.machine_model.name + units = 1 + + charm { + name = var.charm_hitachi_name + channel = var.charm_hitachi_channel + revision = var.charm_hitachi_revision + base = var.charm_hitachi_base + } + + config = merge({ + volume-backend-name = each.key + }, each.value.charm_config, { + # Main array credentials - always required + san-credentials-secret = contains(keys(juju_secret.hitachi_san_credentials), each.key) ? juju_secret.hitachi_san_credentials[each.key].secret_id : "" + + # CHAP credentials - only if CHAP auth is enabled and credentials provided + chap-credentials-secret = contains(keys(juju_secret.hitachi_chap_credentials), each.key) ? juju_secret.hitachi_chap_credentials[each.key].secret_id : "" + + # Mirror CHAP credentials - only if mirror CHAP credentials provided + hitachi-mirror-chap-credentials-secret = contains(keys(juju_secret.hitachi_mirror_chap_credentials), each.key) ? juju_secret.hitachi_mirror_chap_credentials[each.key].secret_id : "" + + # Mirror REST API credentials - only if mirror REST credentials provided + hitachi-mirror-rest-credentials-secret = contains(keys(juju_secret.hitachi_mirror_rest_credentials), each.key) ? juju_secret.hitachi_mirror_rest_credentials[each.key].secret_id : "" + }) + + endpoint_bindings = var.endpoint_bindings +} + +# Integrate Hitachi backends with main cinder-volume +resource "juju_integration" "hitachi_to_cinder_volume" { + for_each = var.hitachi_backends + + model = var.machine_model + + application { + name = juju_application.hitachi_backends[each.key].name + endpoint = var.charm_hitachi_endpoint + } + + application { + name = "cinder-volume" + endpoint = var.charm_hitachi_endpoint + } +} diff --git a/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/outputs.tf b/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/outputs.tf new file mode 100644 index 000000000..493c8151b --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/outputs.tf @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +output "hitachi_backend_applications" { + description = "Map of deployed Hitachi backend applications" + value = { + for name, app in juju_application.hitachi_backends : name => { + name = app.name + model = app.model + units = app.units + } + } +} diff --git a/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/test-variables.tfvars b/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/test-variables.tfvars new file mode 100644 index 000000000..7a07fb91a --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/test-variables.tfvars @@ -0,0 +1,35 @@ +machine_model = "openstack" +charm_hitachi_channel = "latest/edge" +charm_hitachi_revision = null + +hitachi_backends = { + "hitachi-backend-1" = { + charm_config = { + "hitachi-storage-id" = "123456" + "hitachi-pools" = "pool1,pool2" + "san-ip" = "192.168.1.100" + "protocol" = "FC" + "volume-backend-name" = "hitachi-backend-1" + } + + # Main array credentials (always required) + san_username = "maintenance" + san_password = "secret123" + + # CHAP credentials (optional - using iSCSI example) + use_chap_auth = true + chap_username = "iscsi_user" + chap_password = "iscsi_pass" + + # Mirror CHAP credentials (optional) + hitachi_mirror_chap_username = "mirror_chap_user" + hitachi_mirror_chap_password = "mirror_chap_pass" + + # Mirror REST API credentials (optional) + hitachi_mirror_rest_username = "mirror_rest_user" + hitachi_mirror_rest_password = "mirror_rest_pass" + } +} + +machine_ids = [] +endpoint_bindings = null diff --git a/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/variables.tf b/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/variables.tf new file mode 100644 index 000000000..64efd6261 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/variables.tf @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +variable "machine_model" { + description = "Name of the machine model to deploy to" + type = string +} + +variable "charm_hitachi_name" { + description = "Name of the Hitachi charm" + type = string + default = "cinder-volume-hitachi" +} + +variable "charm_hitachi_base" { + description = "Base for the Hitachi charm" + type = string + default = "ubuntu@24.04" +} + +variable "charm_hitachi_channel" { + description = "Operator channel for Hitachi backend deployment" + type = string + default = "latest/edge" +} + +variable "charm_hitachi_endpoint" { + description = "Endpoint name for Hitachi backend integration" + type = string + default = "cinder-volume" +} + +variable "charm_hitachi_revision" { + description = "Operator channel revision for Hitachi backend deployment" + type = number + default = null +} + +variable "hitachi_backends" { + description = "Map of Hitachi backend configurations" + type = map(object({ + charm_config = map(string) + + # Main array credentials (required) + san_username = string + san_password = string + + # CHAP credentials (optional) + use_chap_auth = optional(bool, false) + chap_username = optional(string, "") + chap_password = optional(string, "") + + # Mirror CHAP credentials (optional) + hitachi_mirror_chap_username = optional(string, "") + hitachi_mirror_chap_password = optional(string, "") + + # Mirror REST API credentials (optional) + hitachi_mirror_rest_username = optional(string, "") + hitachi_mirror_rest_password = optional(string, "") + })) + default = {} +} + +variable "machine_ids" { + description = "List of machine ids to include" + type = list(string) + default = [] +} + +variable "endpoint_bindings" { + description = "Endpoint bindings for the applications" + type = set(map(string)) + default = null +} diff --git a/sunbeam-python/sunbeam/storage/backends/purestorage/backend.py b/sunbeam-python/sunbeam/storage/backends/purestorage/backend.py new file mode 100644 index 000000000..8c9b513c3 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/purestorage/backend.py @@ -0,0 +1,407 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Pure Storage FlashArray backend implementation using base step classes.""" + +import logging +from typing import Any, Dict, Literal, Set + +import click + +try: + import yaml as _yaml # type: ignore + + yaml: Any = _yaml +except Exception: # yaml optional; handle gracefully at runtime + yaml = None + +# Import pydantic Field directly +from pydantic import Field +from rich.console import Console + +from sunbeam.core.common import BaseStep +from sunbeam.core.deployment import Deployment +from sunbeam.core.juju import JujuHelper +from sunbeam.core.manifest import Manifest +from sunbeam.core.terraform import TerraformHelper +from sunbeam.storage.base import StorageBackendBase +from sunbeam.storage.models import StorageBackendConfig +from sunbeam.storage.steps import ( + BaseStorageBackendConfigUpdateStep, + BaseStorageBackendDeployStep, + BaseStorageBackendDestroyStep, +) + +LOG = logging.getLogger(__name__) +console = Console() + + +class PureStorageConfig(StorageBackendConfig): + """Configuration model for Pure Storage FlashArray backend. + + This model includes the essential configuration options for deploying + a Pure Storage backend. Additional configuration can be managed dynamically + through the charm configuration system. + """ + + # Required fields (inherited from StorageBackendConfig) + # name: str (from base class) + + # Mandatory connection parameters + san_ip: str = Field( + ..., description="Pure Storage FlashArray management IP or hostname" + ) + pure_api_token: str = Field( + ..., description="REST API authorization token from FlashArray" + ) + protocol: Literal["iscsi", "fc", "nvme"] = Field( + default="fc", description="Pure Storage protocol (iscsi, fc, nvme)" + ) + + # Optional backend configuration + volume_backend_name: str = Field( + default="", description="Name that Cinder will report for this backend" + ) + backend_availability_zone: str = Field( + default="", description="Availability zone to associate with this backend" + ) + + # Protocol-specific options + pure_iscsi_cidr: str = Field( + default="0.0.0.0/0", + description="CIDR of FlashArray iSCSI targets hosts can connect to", + ) + pure_iscsi_cidr_list: str = Field( + default="", description="Comma-separated list of CIDR for iSCSI targets" + ) + pure_nvme_cidr: str = Field( + default="0.0.0.0/0", + description="CIDR of FlashArray NVMe targets hosts can connect to", + ) + pure_nvme_cidr_list: str = Field( + default="", description="Comma-separated list of CIDR for NVMe targets" + ) + pure_nvme_transport: Literal["roce", "tcp"] = Field( + default="roce", description="NVMe transport layer (roce or tcp)" + ) + + # Host and protocol tuning + pure_host_personality: str = Field( + default="", description="Host personality for protocol tuning" + ) + + # Storage management + pure_automatic_max_oversubscription_ratio: bool = Field( + default=True, description="Automatically determine oversubscription ratio" + ) + pure_eradicate_on_delete: bool = Field( + default=False, + description="Immediately eradicate volumes on delete " + "(WARNING: not recoverable)", + ) + + # Replication settings + pure_replica_interval_default: int = Field( + default=3600, description="Snapshot replication interval in seconds" + ) + pure_replica_retention_short_term_default: int = Field( + default=14400, + description="Retain all snapshots on target for this time (seconds)", + ) + pure_replica_retention_long_term_per_day_default: int = Field( + default=3, description="Retain how many snapshots for each day" + ) + pure_replica_retention_long_term_default: int = Field( + default=7, description="Retain snapshots per day on target for this time (days)" + ) + pure_replication_pg_name: str = Field( + default="cinder-group", + description="Pure Protection Group name for async replication", + ) + pure_replication_pod_name: str = Field( + default="cinder-pod", description="Pure Pod name for sync replication" + ) + + # Advanced replication + pure_trisync_enabled: bool = Field( + default=False, description="Enable 3-site replication (sync + async)" + ) + pure_trisync_pg_name: str = Field( + default="cinder-trisync", + description="Protection Group name for trisync replication", + ) + + # SSL and security + driver_ssl_cert_verify: bool = Field( + default=False, description="Enable SSL certificate verification" + ) + driver_ssl_cert_path: str = Field( + default="", description="Path to SSL certificate file or directory" + ) + + # Performance options + use_multipath_for_image_xfer: bool = Field( + default=True, description="Enable multipathing for image transfer operations" + ) + + +class PureStorageBackend(StorageBackendBase): + """Pure Storage FlashArray backend implementation.""" + + name = "purestorage" + display_name = "Pure Storage FlashArray" + charm_name = "cinder-volume-purestorage" + + def __init__(self): + """Initialize Pure Storage backend.""" + super().__init__() + self.tfplan = "purestorage-backend-plan" + self.tfplan_dir = "deploy-purestorage-backend" + + charm_channel = ( + "latest/edge" # Use edge for development, change to stable for production + ) + charm_revision = None # Let Juju pick the latest + charm_base = "ubuntu@24.04" + backend_endpoint = "cinder-volume" + units = 1 + additional_integrations = [] + + @property + def config_class(self) -> type[StorageBackendConfig]: + """Return the configuration class for Pure Storage backend.""" + return PureStorageConfig + + def _get_credential_fields(self) -> Set[str]: + """Get set of credential field names that should be excluded from charm config. + + For Pure Storage backend, we only exclude the meta name field. + """ + return {"name"} # Pure Storage uses API token directly in config + + def get_field_mapping(self) -> Dict[str, str]: + """Get mapping from config fields to charm config options. + + Uses base class automatic mapping and excludes credential fields. + """ + # Start from the base automatic mapping + mapping = super().get_field_mapping() + + # Exclude credential fields + exclude = self._get_credential_fields() + return {k: v for k, v in mapping.items() if k not in exclude} + + # CLI registration uses base class implementation + + def get_terraform_variables( + self, backend_name: str, config: StorageBackendConfig, model: str + ) -> Dict[str, Any]: + """Generate Terraform variables for Pure Storage backend deployment.""" + # Map our configuration fields to the correct charm configuration option names + config_dict = config.model_dump() + field_mapping = self.get_field_mapping() + + # Filter config using base class method + charm_config = self._filter_config_for_charm( + config_dict, field_mapping, exclude_fields=self._get_credential_fields() + ) + + # Build Terraform variables to match the plan's expected format + tfvars = { + "machine_model": model, + "charm_purestorage_name": self.charm_name, + "charm_purestorage_base": self.charm_base, + "charm_purestorage_channel": self.charm_channel, + "charm_purestorage_endpoint": self.backend_endpoint, + "charm_purestorage_revision": self.charm_revision, + "purestorage_backends": { + backend_name: { + "charm_config": charm_config, + } + }, + } + + return tfvars + + def _get_default_config(self) -> PureStorageConfig: + """Get a default configuration instance for comparison.""" + return PureStorageConfig( + name="dummy", + san_ip="dummy", + pure_api_token="dummy", # noqa: S106 + ) + + def prompt_for_config(self, backend_name: str) -> PureStorageConfig: + """Prompt user for Pure Storage-specific configuration.""" + return self._prompt_for_config(backend_name) + + # IP/FQDN validation uses base class implementation + + def _prompt_for_config(self, backend_name: str) -> PureStorageConfig: + """Prompt user for Pure Storage backend configuration.""" + console.print( + "\n[bold blue]Pure Storage FlashArray Backend Configuration[/bold blue]" + ) + console.print("Please provide the required configuration options:") + + # Prompt for required fields + san_ip = click.prompt( + "FlashArray management IP/FQDN", + type=str, + value_proc=self._validate_ip_or_fqdn, + ) + pure_api_token = click.prompt("Pure API token", type=str, hide_input=True) + protocol = click.prompt( + "Protocol", + type=click.Choice(["iscsi", "fc", "nvme"], case_sensitive=False), + default="fc", + ) + + # Optional: prompt for volume backend name (defaults to backend name) + volume_backend_name = click.prompt( + "Volume backend name", type=str, default=backend_name, show_default=True + ) + + # Protocol-specific configuration + pure_iscsi_cidr = "0.0.0.0/0" + pure_nvme_cidr = "0.0.0.0/0" + pure_nvme_transport: Literal["roce", "tcp"] = "roce" + + if protocol.lower() == "iscsi": + console.print("\n[bold yellow]iSCSI Configuration[/bold yellow]") + pure_iscsi_cidr = click.prompt( + "iSCSI target CIDR", type=str, default="0.0.0.0/0", show_default=True + ) + elif protocol.lower() == "nvme": + console.print("\n[bold yellow]NVMe Configuration[/bold yellow]") + pure_nvme_cidr = click.prompt( + "NVMe target CIDR", type=str, default="0.0.0.0/0", show_default=True + ) + transport_choice = click.prompt( + "NVMe transport", + type=click.Choice(["roce", "tcp"], case_sensitive=False), + default="roce", + ) + pure_nvme_transport = transport_choice # type: ignore[assignment] + + # Optional: Host personality + pure_host_personality = "" + if click.confirm( + "\nConfigure host personality for protocol tuning?", default=False + ): + personalities = [ + "aix", + "esxi", + "hitachi-vsp", + "hpux", + "oracle-vm-server", + "solaris", + "vms", + ] + pure_host_personality = click.prompt( + "Host personality", + type=click.Choice(personalities, case_sensitive=False), + default="esxi", + ) + + # Optional: Storage management settings + pure_eradicate_on_delete = False + if click.confirm( + "\nEnable immediate volume eradication on delete?" + "(WARNING: not recoverable)", + default=False, + ): + pure_eradicate_on_delete = True + + return PureStorageConfig( + name=backend_name, + san_ip=san_ip, + pure_api_token=pure_api_token, + protocol=protocol, + volume_backend_name=volume_backend_name, + pure_iscsi_cidr=pure_iscsi_cidr, + pure_nvme_cidr=pure_nvme_cidr, + pure_nvme_transport=pure_nvme_transport, + pure_host_personality=pure_host_personality, + pure_eradicate_on_delete=pure_eradicate_on_delete, + ) + + # Implementation of abstract methods from StorageBackendBase + def create_deploy_step( + self, + deployment: Deployment, + client, + tfhelper: TerraformHelper, + jhelper: JujuHelper, + manifest: Manifest, + backend_name: str, + backend_config: StorageBackendConfig, + model: str, + ) -> BaseStep: + """Create a deployment step for Pure Storage backend.""" + return PureStorageDeployStep( + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + backend_config, + self, + model, + ) + + def create_destroy_step( + self, + deployment: Deployment, + client, + tfhelper: TerraformHelper, + jhelper: JujuHelper, + manifest: Manifest, + backend_name: str, + model: str, + ) -> BaseStep: + """Create a destruction step for Pure Storage backend.""" + return PureStorageDestroyStep( + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + self, + model, + ) + + def create_update_config_step( + self, + deployment: Deployment, + backend_name: str, + config_updates: Dict[str, Any], + ) -> BaseStep: + """Create a configuration update step for Pure Storage backend.""" + return PureStorageUpdateConfigStep( + deployment, + self, + backend_name, + config_updates, + ) + + +# Pure Storage-specific step implementations using base step classes +class PureStorageDeployStep(BaseStorageBackendDeployStep): + """Deploy Pure Storage backend using base step class.""" + + def get_terraform_variables(self) -> Dict[str, Any]: + """Get Terraform variables for Pure Storage backend deployment.""" + return self.backend_instance.get_terraform_variables( + self.backend_name, self.backend_config, self.model + ) + + +class PureStorageDestroyStep(BaseStorageBackendDestroyStep): + """Destroy Pure Storage backend using base step class.""" + + +class PureStorageUpdateConfigStep(BaseStorageBackendConfigUpdateStep): + """Update Pure Storage backend configuration using base step class.""" diff --git a/sunbeam-python/sunbeam/storage/backends/purestorage/cli.py b/sunbeam-python/sunbeam/storage/backends/purestorage/cli.py new file mode 100644 index 000000000..3fa93cd85 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/purestorage/cli.py @@ -0,0 +1,346 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""CLI functionality for Pure Storage storage backend. + +This module contains all CLI-related code following the Hitachi pattern, +including command registration and helper functions. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any, Dict, Optional + +import click +import pydantic +from rich.console import Console + +try: + import yaml as _yaml # type: ignore + + yaml: Any = _yaml +except Exception: # yaml optional; handle gracefully at runtime + yaml = None + +from sunbeam.core.deployment import Deployment +from sunbeam.storage.backends.purestorage.backend import PureStorageBackend +from sunbeam.storage.service import StorageBackendService + +LOG = logging.getLogger(__name__) +console = Console() + + +class PurestorageCLI: + """CLI functionality for Pure Storage storage backend.""" + + def __init__(self, backend: PureStorageBackend): + self.backend = backend + + def _load_config_file(self, path: Optional[Path]) -> Dict[str, Any]: + """Load YAML or JSON config file into a dictionary. + + YAML is preferred if PyYAML is available, otherwise JSON is used. + """ + if not path: + return {} + text = path.read_text() + if yaml is not None: + return dict(yaml.safe_load(text) or {}) + + return dict(json.loads(text)) + + def register_add_cli(self, add: click.Group) -> None: # noqa: C901 + """Register 'sunbeam storage add purestorage'. + + Includes typed options and a --config-file flag. + """ + + def _click_type_for(field_info) -> click.types.ParamType: + # Map pydantic field to Click type + ann = getattr(field_info, "annotation", None) + typ = None + if ann is not None: + typ = str(ann) + elif hasattr(field_info, "type_"): + typ = str(field_info.type_) + if typ and ("int" in typ): + return click.INT + if typ and ("float" in typ): + return click.FLOAT + if typ and ("bool" in typ): + return click.BOOL + return click.STRING + + def _build_params(required_all: bool) -> list: + params: list = [] + # name option (not required; prompt in interactive mode) + params.append( + click.Option(["--name"], type=str, required=False, help="Backend name") + ) + # config file (optional) + params.append( + click.Option( + ["--config-file"], + type=click.Path(exists=True, dir_okay=False, path_type=Path), + required=False, + help="YAML/JSON config file", + ) + ) + # Model-derived options + fields = getattr(self.backend.config_class, "model_fields", {}) + for fname, finfo in fields.items(): + if fname == "name": + continue + opt_name = "--" + fname.replace("_", "-") + click_type = _click_type_for(finfo) + # Determine requiredness for add (respect model) + # For interactive UX, keep CLI options optional; the model + # enforces requiredness. + is_required = False + # Help text + descr = None + if hasattr(finfo, "field_info") and hasattr( + finfo.field_info, "description" + ): + descr = finfo.field_info.description + elif hasattr(finfo, "description"): + descr = finfo.description + params.append( + click.Option( + [opt_name], type=click_type, required=is_required, help=descr + ) + ) + return params + + def _build_config_from_kwargs(kwargs: Dict[str, Any]) -> Dict[str, Any]: + # Extract name and field values from kwargs + # (Click converts dashes to underscores) + cfg: Dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + cfg[k] = v + return cfg + + def add_callback(**kwargs): + deployment: Deployment = click.get_current_context().obj + cfg_file = kwargs.pop("config_file", None) + file_cfg = self._load_config_file(cfg_file) + cli_cfg = _build_config_from_kwargs(kwargs) + # Determine if interactive: no config-file and no CLI options supplied + provided_cli_values = {k: v for k, v in cli_cfg.items() if v is not None} + interactive = not file_cfg and not provided_cli_values + + if interactive: + # Prompt for name and full config via helper + console.print( + f"[blue]Setting up {self.backend.display_name} backend[/blue]" + ) + backend_name = click.prompt("Backend name", type=str) + config_instance = self.backend.prompt_for_config(backend_name) + config_instance.name = backend_name + self.backend.add_backend( + deployment, backend_name, config_instance, console + ) + return + + # Non-interactive path: merge file and CLI, validate + merged = {**file_cfg, **provided_cli_values} + try: + config_instance = self.backend.config_class(**merged) + except pydantic.ValidationError as e: + console.print("[red]Configuration validation error:[/red]") + for error in e.errors(): + field_name = error.get("loc", ["unknown"])[0] + # Convert field name to CLI parameter format + cli_param = f"--{field_name.replace('_', '-')}" + console.print(f" {cli_param}: {error['msg']}") + raise click.Abort() + backend_name = merged.get("name") + if not backend_name: + raise click.BadParameter( + "--name is required when not running interactively" + ) + self.backend.add_backend(deployment, backend_name, config_instance, console) + + # Build command dynamically with parameters + params = _build_params(required_all=False) + help_text = ( + f"Add {self.backend.display_name} backend.\n\n" + "Behavior:\n" + "- If no options are provided, runs in interactive mode and prompts " + "for all required fields.\n" + "- If options and/or --config-file are provided, runs non-" + "interactively and validates against the model.\n" + "- In non-interactive mode, --name is required (or supplied via " + "--config-file).\n\n" + "Examples:\n" + " sunbeam storage add purestorage\n" + " sunbeam storage add purestorage --config-file purestorage.yaml\n" + " sunbeam storage add purestorage --name mypure --san-ip 10.0.0.10 " + "--pure-api-token mytoken\n" + ) + cmd = click.Command( + name=self.backend.name, + params=params, + callback=add_callback, + help=help_text, + ) + add.add_command(cmd) + + def register_cli( # noqa: C901 + self, + remove: click.Group, + config_show: click.Group, + config_set: click.Group, + config_options: click.Group, + deployment: Deployment, + ) -> None: + """Register management commands for Pure Storage backend.""" + + @click.command(name=self.backend.name) + @click.argument("backend_name", type=str) + @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") + @click.pass_context + def remove_purestorage(ctx, backend_name: str, yes: bool): + service = self.backend._get_service(deployment) + if not service.backend_exists(backend_name, self.backend.name): + console.print(f"[red]Error: Backend '{backend_name}' not found[/red]") + raise click.Abort() + if not yes: + click.confirm( + f"Remove {self.backend.display_name} backend '{backend_name}'?", + abort=True, + ) + try: + self.backend.remove_backend(deployment, backend_name, console) + except Exception as e: + console.print(f"[red]Error removing backend: {e}[/red]") + raise click.Abort() + + remove.add_command(remove_purestorage) + + @click.command(name=self.backend.name) + @click.argument("backend_name", type=str) + @click.pass_context + def config_show_purestorage(ctx, backend_name: str): + service = self.backend._get_service(deployment) + config = service.get_backend_config(backend_name, self.backend.name) + self.backend.display_config_table(backend_name, config) + + config_show.add_command(config_show_purestorage) + + # Build typed options for config set (only provided options are updated) + def _build_set_params() -> list: + params = [] + # Add config-file option first + params.append( + click.Option( + ["--config-file"], + type=click.Path( + exists=True, dir_okay=False, readable=True, path_type=Path + ), + required=False, + help="YAML/JSON config file with updates", + ) + ) + fields = getattr(self.backend.config_class, "model_fields", {}) + for fname, finfo in fields.items(): + if fname == "name": + continue + opt = "--" + fname.replace("_", "-") + # For updates, make everything optional with default None + # so we can detect presence + click_type: click.ParamType = click.STRING + ann = getattr(finfo, "annotation", None) + if ann is not None and ("bool" in str(ann)): + click_type = click.BOOL + elif ann is not None and ("int" in str(ann)): + click_type = click.INT + elif ann is not None and ("float" in str(ann)): + click_type = click.FLOAT + descr = None + if hasattr(finfo, "field_info") and hasattr( + finfo.field_info, "description" + ): + descr = finfo.field_info.description + elif hasattr(finfo, "description"): + descr = finfo.description + params.append( + click.Option( + [opt], type=click_type, required=False, default=None, help=descr + ) + ) + return params + + def set_callback(backend_name: str, **kwargs): + cfg_file = kwargs.pop("config_file", None) + file_cfg = {} + if cfg_file: + file_cfg = self._load_config_file(cfg_file) + # Only include keys that were explicitly provided (value not None) + updates = {k: v for k, v in kwargs.items() if v is not None} + updates = {**file_cfg, **updates} + try: + # Get current configuration and merge with updates for validation + service = StorageBackendService(deployment) + current_config = service.get_backend_config( + backend_name, self.backend.name + ) + + # Create a merged config for validation + # Current config comes from charm (kebab-case keys), + # convert to snake_case + merged_config = {} + for key, value in current_config.items(): + snake_key = key.replace("-", "_") + merged_config[snake_key] = value + + # Updates from CLI are already in snake_case + # (Click converts --protocol to protocol) + merged_config.update(updates) + merged_config["name"] = backend_name # Ensure name is set + _ = self.backend.config_class(**merged_config) + except pydantic.ValidationError as e: + console.print("[red]Configuration validation error:[/red]") + for error in e.errors(): + field_name = error["loc"][0] if error.get("loc") else "unknown" + # Convert field name to CLI parameter format + cli_param = f"--{str(field_name).replace('_', '-')}" + console.print(f" {cli_param}: {error['msg']}") + raise click.ClickException( + "Configuration update failed due to validation error" + ) + try: + self.backend.update_backend_config(deployment, backend_name, updates) + console.print( + ( + f"[green]Configuration updated for {self.backend.display_name} " + f"backend '{backend_name}'[/green]" + ) + ) + except Exception as e: + console.print(f"[red]Failed to update configuration: {e}[/red]") + raise click.ClickException(f"Configuration update failed: {e}") + + set_params = _build_set_params() + # Add backend_name as a required argument + set_params.insert(0, click.Argument(["backend_name"], type=str, required=True)) + set_cmd = click.Command( + name=self.backend.name, + params=set_params, + callback=set_callback, + help="Set configuration options", + ) + config_set.add_command(set_cmd) + + @click.command(name=self.backend.name) + @click.pass_context + def config_options_purestorage(ctx): + self.backend.display_config_options() + + + config_options.add_command(config_options_purestorage) diff --git a/sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/main.tf b/sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/main.tf new file mode 100644 index 000000000..7913939ef --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/main.tf @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +terraform { + required_providers { + juju = { + source = "juju/juju" + version = "= 0.20.0" + } + } +} + +provider "juju" {} + +data "juju_model" "machine_model" { + name = var.machine_model +} + +# Deploy Pure Storage backend charms +resource "juju_application" "purestorage_backends" { + for_each = var.purestorage_backends + + name = each.key + model = data.juju_model.machine_model.name + units = 1 + + charm { + name = var.charm_purestorage_name + channel = var.charm_purestorage_channel + revision = var.charm_purestorage_revision + base = var.charm_purestorage_base + } + + config = merge({ + volume-backend-name = each.key + }, each.value.charm_config) + + endpoint_bindings = var.endpoint_bindings +} + +# Integrate Pure Storage backends with main cinder-volume +resource "juju_integration" "purestorage_to_cinder_volume" { + for_each = var.purestorage_backends + + model = var.machine_model + + application { + name = juju_application.purestorage_backends[each.key].name + endpoint = var.charm_purestorage_endpoint + } + + application { + name = "cinder-volume" + endpoint = var.charm_purestorage_endpoint + } +} diff --git a/sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/outputs.tf b/sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/outputs.tf new file mode 100644 index 000000000..e648676da --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/outputs.tf @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +output "purestorage_backend_applications" { + description = "Map of deployed Pure Storage backend applications" + value = { + for name, app in juju_application.purestorage_backends : name => { + name = app.name + model = app.model + units = app.units + } + } +} diff --git a/sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/variables.tf b/sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/variables.tf new file mode 100644 index 000000000..6c6bacf6f --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/variables.tf @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +variable "machine_model" { + description = "Name of the machine model to deploy to" + type = string +} + +variable "charm_purestorage_name" { + description = "Name of the Pure Storage charm" + type = string + default = "cinder-volume-purestorage" +} + +variable "charm_purestorage_base" { + description = "Base for the Pure Storage charm" + type = string + default = "ubuntu@24.04" +} + +variable "charm_purestorage_channel" { + description = "Operator channel for Pure Storage backend deployment" + type = string + default = "latest/edge" +} + +variable "charm_purestorage_endpoint" { + description = "Endpoint name for Pure Storage backend integration" + type = string + default = "cinder-volume" +} + +variable "charm_purestorage_revision" { + description = "Operator channel revision for Pure Storage backend deployment" + type = number + default = null +} + +variable "purestorage_backends" { + description = "Map of Pure Storage backend configurations" + type = map(object({ + charm_config = map(string) + })) + default = {} +} + +variable "machine_ids" { + description = "List of machine ids to include" + type = list(string) + default = [] +} + +variable "endpoint_bindings" { + description = "Endpoint bindings for the applications" + type = set(map(string)) + default = null +} + diff --git a/sunbeam-python/sunbeam/storage/base.py b/sunbeam-python/sunbeam/storage/base.py new file mode 100644 index 000000000..7e94b7dbc --- /dev/null +++ b/sunbeam-python/sunbeam/storage/base.py @@ -0,0 +1,670 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Storage backend base class with integrated Terraform functionality.""" + +import ipaddress +import logging +import re +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +import click +from packaging.version import Version +from rich.console import Console +from rich.table import Table + +from sunbeam.core.common import BaseStep, run_plan +from sunbeam.core.deployment import Deployment +from sunbeam.core.juju import JujuHelper +from sunbeam.core.manifest import Manifest +from sunbeam.core.terraform import TerraformHelper, TerraformInitStep + +from .models import ( + BackendAlreadyExistsException, + BackendNotFoundException, + StorageBackendConfig, +) +from .service import StorageBackendService +from .steps import ValidateStoragePrerequisitesStep + +LOG = logging.getLogger(__name__) +console = Console() + +# Juju application name validation pattern +# Based on Juju's naming rules: must start with letter, contain only +# letters, numbers, hyphens. Cannot end with hyphen, cannot have +# consecutive hyphens, cannot have numbers after final hyphen +JUJU_APP_NAME_PATTERN = re.compile(r"^[a-z]([a-z0-9]*(-[a-z0-9]*)*)?$") + +# Regex pattern for validating FQDN (Fully Qualified Domain Name) +FQDN_PATTERN = ( + r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?" + r"(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$" +) + + +def validate_juju_application_name(name: str) -> bool: + """Validate that a name is a valid Juju application name. + + Args: + name: The application name to validate + + Returns: + True if valid, False otherwise + """ + if not name: + return False + + # Check basic pattern + if not JUJU_APP_NAME_PATTERN.match(name): + return False + + # Additional checks for edge cases + if name.endswith("-"): + return False + + if "--" in name: + return False + + # Check that numbers don't appear after the final hyphen + if "-" in name: + parts = name.split("-") + last_part = parts[-1] + if any(char.isdigit() for char in last_part): + return False + + return True + + +class StorageBackendBase(ABC): + """Base class for storage backends with integrated Terraform functionality.""" + + name: str = "base" + display_name: str = "Base Storage Backend" + version = Version("0.0.1") + tf_plan_location = "FEATURE_REPO" # Plans stored in feature directory + user_manifest = None # Path to user manifest file + + def __init__(self): + """Initialize storage backend.""" + self.tfplan = "storage-backend-plan" + self.tfplan_dir = "deploy-storage-backend" + self._manifest: Optional[Manifest] = None + self.service: Optional[StorageBackendService] = None + + def _get_service(self, deployment: Deployment) -> StorageBackendService: + """Get or create the storage backend service.""" + if self.service is None: + self.service = StorageBackendService(deployment) + return self.service + + # Common CLI registration pattern (Abstraction 3: CLI registration) + def register_add_cli(self, add: click.Group) -> None: # noqa: F811 + """Register 'sunbeam storage add ' command. + + Default implementation delegates to CLI class following the pattern. + Subclasses can override if they need custom behavior. + """ + cli_class = self._get_cli_class() + cli = cli_class(self) + cli.register_add_cli(add) + + def register_cli( # noqa: F811 + self, + remove: click.Group, + config_show: click.Group, + config_set: click.Group, + config_options: click.Group, + deployment: Deployment, + ) -> None: + """Register management commands for this backend. + + Default implementation delegates to CLI class following the pattern. + Subclasses can override if they need custom behavior. + """ + cli_class = self._get_cli_class() + cli = cli_class(self) + cli.register_cli(remove, config_show, config_set, config_options, deployment) + + # Terraform-related properties and methods + @property + def manifest(self) -> Manifest: + """Return the manifest.""" + if self._manifest: + return self._manifest + + manifest = click.get_current_context().obj.get_manifest(self.user_manifest) + self._manifest = manifest + if self._manifest is None: + raise ValueError("Failed to load manifest") + return self._manifest + + @property + def tfvar_config_key(self) -> str: + """Config key for storing Terraform variables in clusterd.""" + return f"TerraformVarsStorageBackends{self.name.title()}" + + # Abstract methods that each backend must implement + @abstractmethod + def create_deploy_step( + self, + deployment: Deployment, + client, + tfhelper: TerraformHelper, + jhelper: JujuHelper, + manifest: Manifest, + backend_name: str, + backend_config: StorageBackendConfig, + model: str, + ) -> BaseStep: + """Create a deployment step for this backend.""" + pass + + @abstractmethod + def create_destroy_step( + self, + deployment: Deployment, + client, + tfhelper: TerraformHelper, + jhelper: JujuHelper, + manifest: Manifest, + backend_name: str, + model: str, + ) -> BaseStep: + """Create a destruction step for this backend.""" + pass + + @abstractmethod + def create_update_config_step( + self, + deployment: Deployment, + backend_name: str, + config_updates: Dict[str, Any], + ) -> BaseStep: + """Create a configuration update step for this backend.""" + pass + + def register_terraform_plan(self, deployment: Deployment) -> None: + """Register storage backend Terraform plan with deployment system.""" + import shutil + + from sunbeam.core.terraform import TerraformHelper + + # Get the plan source path + backend_self_contained = ( + Path(__file__).parent / "backends" / self.name / self.tfplan_dir + ) + + if backend_self_contained.exists(): + plan_source = backend_self_contained + else: + raise FileNotFoundError( + f"Terraform plan not found at {backend_self_contained}" + ) + + # Copy plan to deployment's plans directory + dst = deployment.plans_directory / self.tfplan_dir + shutil.copytree(plan_source, dst, dirs_exist_ok=True) + + # Create TerraformHelper + env = {} + env.update(deployment._get_juju_clusterd_env()) + env.update(deployment.get_proxy_settings()) + + tfhelper = TerraformHelper( + path=dst, + plan=self.tfplan, + tfvar_map={}, + backend="http", + env=env, + clusterd_address=deployment.get_clusterd_http_address(), + ) + + # Register the helper with the deployment's tfhelpers + deployment._tfhelpers[self.tfplan] = tfhelper + + def add_backend( + self, + deployment: Deployment, + backend_name: str, + config: StorageBackendConfig, + console: Console, + ) -> None: + """Add a storage backend using Terraform deployment.""" + # Validate backend name follows Juju application naming rules + if not validate_juju_application_name(backend_name): + raise click.ClickException( + f"Invalid backend name '{backend_name}'. " + f"Backend names must be valid Juju application names: " + f"start with a letter, contain only lowercase letters, numbers," + f"and hyphens, cannot end with hyphen, cannot" + f"have consecutive hyphens, and cannot have numbers" + f"after the final hyphen." + ) + + service = self._get_service(deployment) + if service.backend_exists(backend_name, self.name): + raise BackendAlreadyExistsException( + f"Backend '{backend_name}' already exists" + ) + + # Register our Terraform plan with the deployment system + self.register_terraform_plan(deployment) + + # Get standard Sunbeam helpers + client = deployment.get_client() + tfhelper = deployment.get_tfhelper(self.tfplan) + jhelper = JujuHelper(deployment.juju_controller) + + plan = [ + ValidateStoragePrerequisitesStep(deployment, client, jhelper), + TerraformInitStep(tfhelper), + self.create_deploy_step( + deployment, + client, + tfhelper, + jhelper, + self.manifest, + backend_name, + config, + deployment.openstack_machines_model, + ), + ] + + run_plan(plan, console) + + def _get_field_descriptions(self, config_class) -> dict: + """Extract field descriptions from a Pydantic v2 model class.""" + desc: Dict[str, str] = {} + if hasattr(config_class, "model_fields"): + for field_name, field_info in config_class.model_fields.items(): + desc[field_name] = getattr( + getattr(field_info, "field_info", field_info), + "description", + "No description available", + ) + return desc + + def _format_config_value(self, key: str, value) -> str: + """Format configuration value for display, masking sensitive data.""" + display_value = str(value) + if any(s in key.lower() for s in ["password", "secret", "token", "key"]): + display_value = "*" * min(8, len(display_value)) if display_value else "" + if len(display_value) > 23: + display_value = display_value[:20] + "..." + return display_value + + def _extract_field_info(self, field_info) -> tuple: + """Extract field type, default value, and description from field info.""" + if hasattr(field_info, "type_"): + field_type = str(field_info.type_).replace("", "") + elif hasattr(field_info, "annotation"): + field_type = ( + str(field_info.annotation).replace("", "") + ) + else: + field_type = "str" + + if hasattr(field_info, "default"): + default_value = ( + str(field_info.default) if field_info.default is not ... else "Required" + ) + else: + default_value = "Unknown" + + if hasattr(field_info, "field_info") and hasattr( + field_info.field_info, "description" + ): + description = field_info.field_info.description or "No description" + elif hasattr(field_info, "description"): + description = field_info.description or "No description" + else: + description = "No description" + + return field_type, default_value, description + + def display_config_options(self) -> None: + """Display available configuration options for this backend.""" + console.print( + f"[blue]Available configuration options for {self.display_name}:[/blue]" + ) + config_class = self.config_class + fields = getattr(config_class, "model_fields", {}) + if not fields: + console.print( + " Configuration options are managed dynamically via Terraform." + ) + console.print( + " Use 'sunbeam storage config show' to see current configuration." + ) + return + + table = Table(show_header=True, header_style="bold blue") + table.add_column("Option", style="cyan") + table.add_column("Type", style="green") + table.add_column("Default", style="yellow") + table.add_column("Description", style="white") + + for field_name, finfo in fields.items(): + if field_name == "name": + continue + try: + ftype, default, descr = self._extract_field_info(finfo) + table.add_row(field_name, ftype, default, descr) + except Exception: + table.add_row(field_name, "str", "Unknown", "Configuration option") + + console.print(table) + + def display_config_table(self, backend_name: str, config: dict) -> None: + """Display current configuration in a formatted table for this backend.""" + table = Table( + title=f"Configuration for {self.display_name} backend '{backend_name}'", + show_header=True, + header_style="bold blue", + title_style="bold cyan", + border_style="blue", + ) + + table.add_column("Option", style="cyan", no_wrap=True, width=30) + table.add_column("Value", style="green", width=25) + table.add_column("Description", style="dim", width=50) + + field_descriptions = self._get_field_descriptions(self.config_class) + for key, value in sorted(config.items()): + # Skip empty values (None, empty string, empty dict, empty list) + # But keep 0 and False as valid values + if ( + value is None + or value == "" + or (isinstance(value, (dict, list)) and len(value) == 0) + ): + continue + + display_value = self._format_config_value(key, value) + description = field_descriptions.get(key, "Configuration option") + if len(description) > 47: + description = description[:44] + "..." + table.add_row(key, display_value, description) + + if not config: + console.print( + ( + f"[yellow]No configuration found for {self.name} " + f"backend '{backend_name}'[/yellow]" + ) + ) + else: + console.print(table) + console.print( + ( + f"[green]Configuration displayed for {self.display_name} " + f"backend '{backend_name}'[/green]" + ) + ) + + def remove_backend( + self, deployment: Deployment, backend_name: str, console: Console + ) -> None: + """Remove a storage backend using Terraform.""" + service = self._get_service(deployment) + if not service.backend_exists(backend_name, self.name): + raise BackendNotFoundException(f"Backend '{backend_name}' not found") + + # Register our Terraform plan with the deployment system + self.register_terraform_plan(deployment) + + # Get standard Sunbeam helpers + client = deployment.get_client() + tfhelper = deployment.get_tfhelper(self.tfplan) + jhelper = JujuHelper(deployment.juju_controller) + + # Create removal plan - each backend should implement its own destroy step + plan = [ + ValidateStoragePrerequisitesStep(deployment, client, jhelper), + TerraformInitStep(tfhelper), + self.create_destroy_step( + deployment, + client, + tfhelper, + jhelper, + self.manifest, + backend_name, + deployment.openstack_machines_model, + ), + ] + + run_plan(plan, console) + + def update_backend_config( + self, deployment: Deployment, backend_name: str, config_updates: Dict[str, Any] + ) -> None: + """Update backend configuration using Terraform.""" + service = self._get_service(deployment) + if not service.backend_exists(backend_name, self.name): + raise BackendNotFoundException(f"Backend '{backend_name}' not found") + + # Ensure the Terraform plan is registered so we can obtain its tfhelper + self.register_terraform_plan(deployment) + + plan = [ + TerraformInitStep(deployment.get_tfhelper(self.tfplan)), + self.create_update_config_step(deployment, backend_name, config_updates), + ] + + run_plan(plan, console) + + @property + def config_class(self) -> type[StorageBackendConfig]: + """Return the configuration class for this backend.""" + return StorageBackendConfig + + # Backend-specific properties that subclasses should override + @property + def backend_type(self) -> str: + """Backend type identifier. Override in subclasses.""" + return self.name + + @property + def charm_name(self) -> str: + """Charm name for this backend. Override in subclasses.""" + raise NotImplementedError("Subclasses must define charm_name") + + @property + def charm_channel(self) -> str: + """Charm channel for this backend. Override in subclasses.""" + return "stable" + + @property + def charm_revision(self) -> Optional[int]: + """Charm revision for this backend. Override in subclasses.""" + return None + + @property + def charm_base(self) -> str: + """Charm base for this backend. Override in subclasses.""" + return "ubuntu@22.04" + + @property + def backend_endpoint(self) -> str: + """Backend endpoint name for integration. Override in subclasses.""" + return "cinder-volume" + + @property + def units(self) -> int: + """Number of units to deploy. Override in subclasses.""" + return 1 + + @property + def additional_integrations(self) -> List[str]: + """Additional integrations for this backend. Override in subclasses.""" + return [] + + @abstractmethod + def get_terraform_variables( + self, backend_name: str, config: StorageBackendConfig, model: str + ) -> Dict[str, Any]: + """Generate Terraform variables for this backend. Override in subclasses.""" + raise NotImplementedError("Subclasses must implement get_terraform_variables") + + def get_field_mapping(self) -> Dict[str, str]: + """Get mapping from config fields to charm config options. + + Maps Pydantic field names (with underscores) to charm config option + names (with hyphens). Uses the config_class to automatically generate + the mapping from Pydantic model fields. + """ + config_class = self.config_class + # Use model_fields for Pydantic v2 + model_fields = getattr(config_class, "model_fields", {}) + field_names = model_fields.keys() if model_fields else [] + + return {key: key.replace("_", "-") for key in field_names} + + @abstractmethod + def prompt_for_config(self, backend_name: str) -> StorageBackendConfig: + """Prompt user for backend-specific configuration. Override in subclasses.""" + raise NotImplementedError("Subclasses must implement prompt_for_config") + + # Common utility methods + def _filter_config_for_charm( + self, + config_dict: Dict[str, Any], + field_mapping: Dict[str, str], + exclude_fields: Set[str] | None = None, + ) -> Dict[str, Any]: + """Filter configuration dictionary for charm deployment. + + Only includes explicitly set values (non-default, non-empty) to avoid + sending unnecessary configuration to the charm. + + Args: + config_dict: Configuration dictionary to filter + field_mapping: Mapping from config keys to charm config keys + exclude_fields: Set of fields to exclude from filtering + + Returns: + Filtered charm configuration dictionary + """ + exclude_fields = exclude_fields or set() + + # Get default values for comparison + default_config = self._get_default_config() + default_dict = default_config.model_dump() + + charm_config = {} + for key, value in config_dict.items(): + # Skip excluded fields + if key in exclude_fields: + continue + + if key in field_mapping: + # Only include explicitly set values (non-default, non-empty) + if self._should_include_config_value(key, value, default_dict.get(key)): + charm_config[field_mapping[key]] = value + + return charm_config + + def _should_include_config_value( + self, key: str, value: Any, default_value: Any + ) -> bool: + """Determine if a configuration value should be included in charm config. + + Args: + key: Configuration field name + value: Current value + default_value: Default value for this field + + Returns: + True if the value should be sent to the charm, False otherwise + """ + # Always include the 'name' field as it's required + if key == "name": + return True + + # Skip None values + if value is None: + return False + + # Skip empty strings + if isinstance(value, str) and value.strip() == "": + return False + + # Skip empty lists + if isinstance(value, list) and len(value) == 0: + return False + + # Skip empty dictionaries + if isinstance(value, dict) and len(value) == 0: + return False + + # Skip values that match the default + if value == default_value: + return False + + # Include all other values + return True + + @abstractmethod + def _get_default_config(self) -> StorageBackendConfig: + """Get a default configuration instance for comparison. + + Subclasses must implement this to provide a default config instance + with dummy values for required fields. + """ + raise NotImplementedError("Subclasses must implement _get_default_config") + + def _get_credential_fields(self) -> Set[str]: + """Get set of credential field names that should be excluded from charm config. + + Subclasses can override this to specify which fields contain credentials + that should not be sent to the charm configuration. + + Returns: + Set of field names containing credentials + """ + return {"name"} # Default: only exclude the name field + + # Common utility methods (Abstraction 2: IP/FQDN validation) + @staticmethod + def _validate_ip_or_fqdn(value: str) -> str: + """Validate IP address or FQDN. + + Args: + value: IP address or FQDN to validate + + Returns: + The validated value + + Raises: + click.BadParameter: If value is not a valid IP or FQDN + """ + try: + ipaddress.ip_address(value) + return value + except ValueError: + # If not a valid IP, check if it's a valid FQDN + if re.match(FQDN_PATTERN, value): + return value + raise click.BadParameter("Must be a valid IP address or FQDN") + + def _get_cli_class(self): + """Get the CLI class for this backend. + + Subclasses should override this to return their CLI class. + Default implementation attempts to import based on naming convention. + """ + try: + # Try to import CLI class based on naming convention + module_path = f"sunbeam.storage.backends.{self.name}.cli" + cli_module = __import__(module_path, fromlist=[f"{self.name.title()}CLI"]) + cli_class_name = f"{self.name.title()}CLI" + return getattr(cli_module, cli_class_name) + except (ImportError, AttributeError): + raise NotImplementedError( + f"Subclasses must implement _get_cli_class or " + f"follow naming convention: " + f"sunbeam.storage.backends.{self.name}.cli.{self.name.title()}CLI" + ) diff --git a/sunbeam-python/sunbeam/storage/models.py b/sunbeam-python/sunbeam/storage/models.py new file mode 100644 index 000000000..9aa09f205 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/models.py @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Storage backend models and exceptions.""" + +from typing import Any, Dict + +from pydantic import BaseModel, Field + +from sunbeam.core.common import SunbeamException + +# ============================================================================= +# Exceptions +# ============================================================================= + + +class StorageBackendException(SunbeamException): + """Base exception for storage backend operations.""" + + pass + + +class BackendNotFoundException(StorageBackendException): + """Raised when storage backend is not found.""" + + pass + + +class BackendAlreadyExistsException(StorageBackendException): + """Raised when storage backend already exists.""" + + pass + + +class BackendValidationException(StorageBackendException): + """Raised when storage backend configuration is invalid.""" + + pass + + +# ============================================================================= +# Data Models +# ============================================================================= + + +class StorageBackendConfig(BaseModel): + """Base configuration model for storage backends.""" + + name: str = Field(..., description="Backend name") + + +class StorageBackendInfo(BaseModel): + """Information about a deployed storage backend.""" + + name: str + backend_type: str + status: str + charm: str + config: Dict[str, Any] = {} diff --git a/sunbeam-python/sunbeam/storage/registry.py b/sunbeam-python/sunbeam/storage/registry.py new file mode 100644 index 000000000..867259987 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/registry.py @@ -0,0 +1,192 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +import importlib +import logging +import pathlib +from typing import Dict, List + +import click +from rich.console import Console +from rich.table import Table + +from sunbeam.core.deployment import Deployment +from sunbeam.storage.base import StorageBackendBase +from sunbeam.storage.models import StorageBackendInfo +from sunbeam.storage.service import StorageBackendService + +LOG = logging.getLogger(__name__) +console = Console() + +# Global registry for storage backends +_STORAGE_BACKENDS: Dict[str, StorageBackendBase] = {} + + +class StorageBackendRegistry: + """Registry for managing storage backends.""" + + def __init__(self): + self._backends: Dict[str, StorageBackendBase] = {} + self._loaded = False + + def _load_backends(self) -> None: + """Load all storage backends from the storage/backends directory.""" + if self._loaded: + return + + LOG.debug("Loading storage backends") + import sunbeam.storage.backends + + sunbeam_storage_backends = pathlib.Path( + sunbeam.storage.backends.__file__ + ).parent + + for path in sunbeam_storage_backends.iterdir(): + # Skip non-directories and special files + if not path.is_dir() or path.name.startswith("_") or path.name == "etc": + continue + + backend_name = path.name + backend_module_path = path / "backend.py" + + # Check if the backend.py file exists in the backend directory + if not backend_module_path.exists(): + LOG.debug(f"Skipping {backend_name}: no backend.py file found") + continue + + try: + LOG.debug(f"Loading storage backend: {backend_name}") + # Import the backend module from the backend subdirectory + mod = importlib.import_module( + f"sunbeam.storage.backends.{backend_name}.backend" + ) + + # Look for backend classes + for attr_name in dir(mod): + attr = getattr(mod, attr_name) + if ( + isinstance(attr, type) + and issubclass(attr, StorageBackendBase) + and attr != StorageBackendBase + ): + backend_instance = attr() + self._backends[backend_instance.name] = backend_instance + LOG.debug( + f"Registered storage backend: {backend_instance.name}" + ) + + except Exception as e: + LOG.warning(f"Failed to load storage backend {backend_name}: {e}") + + self._loaded = True + + def get_backend(self, name: str) -> StorageBackendBase: + """Get a storage backend by name.""" + self._load_backends() + if name not in self._backends: + raise ValueError(f"Storage backend '{name}' not found") + return self._backends[name] + + def list_backends(self) -> Dict[str, StorageBackendBase]: + """Get all available storage backends.""" + self._load_backends() + return self._backends.copy() + + def get_backends(self) -> Dict[str, StorageBackendBase]: + """Get all available storage backends (alias for list_backends).""" + return self.list_backends() + + def register_cli_commands( + self, storage_group: click.Group, deployment: Deployment + ) -> None: + """Register all backend commands with the storage CLI group. + + This now follows the provider pattern: create stable top-level groups + and let each backend self-register its subcommands under those groups. + The CLI UX remains the same, e.g.: + sunbeam storage add [...] + sunbeam storage remove + sunbeam storage list all + sunbeam storage config show + sunbeam storage config set key=value ... + sunbeam storage config reset key ... + sunbeam storage config options [name] + """ + self._load_backends() + + # Top-level subgroups + add_group = click.Group(name="add") + remove_group = click.Group(name="remove") + list_group = click.Group(name="list") + config_group = click.Group(name="config") + + # Config subgroups for backend-specific commands + config_show = click.Group(name="show") + config_set = click.Group(name="set") + config_reset = click.Group(name="reset") + config_options = click.Group(name="options") + + # Attach config subgroups to the config group + config_group.add_command(config_show) + config_group.add_command(config_set) + config_group.add_command(config_reset) + config_group.add_command(config_options) + + # List commands (keep generic 'all') + @click.command(name="all") + @click.pass_context + def list_all(ctx): + """List all storage backends.""" + service = StorageBackendService(deployment) + backends = service.list_backends() + self._display_backends_table(backends) + + list_group.add_command(list_all) + + # Delegate CLI registration to each backend + for backend in self._backends.values(): + try: + backend.register_add_cli(add_group) + backend.register_cli( + remove_group, + config_show, + config_set, + config_options, + deployment, + ) + except Exception as e: + backend_name = getattr(backend, "name", "unknown") + LOG.warning( + "Backend %s failed to register CLI: %s", + backend_name, + e, + ) + + # Mount groups under storage + storage_group.add_command(add_group) + storage_group.add_command(remove_group) + storage_group.add_command(list_group) + storage_group.add_command(config_group) + + def _display_backends_table(self, backends: List[StorageBackendInfo]) -> None: + """Display backends in a formatted table.""" + if not backends: + console.print("[yellow]No storage backends found[/yellow]") + return + + table = Table(title="Storage Backends") + table.add_column("Name", style="cyan") + table.add_column("Type", style="magenta") + table.add_column("Status", style="green") + table.add_column("Charm", style="blue") + + for backend in backends: + status_style = "green" if backend.status == "active" else "red" + table.add_row( + backend.name, + backend.backend_type, + f"[{status_style}]{backend.status}[/{status_style}]", + backend.charm, + ) + + console.print(table) diff --git a/sunbeam-python/sunbeam/storage/service.py b/sunbeam-python/sunbeam/storage/service.py new file mode 100644 index 000000000..31e491158 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/service.py @@ -0,0 +1,248 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Storage backend service layer.""" + +import logging +from typing import Any, Dict, List + +from rich.console import Console + +from sunbeam.clusterd.service import ConfigItemNotFoundException +from sunbeam.core.common import read_config +from sunbeam.core.deployment import Deployment +from sunbeam.core.juju import JujuHelper + +from .models import ( + BackendNotFoundException, + StorageBackendException, + StorageBackendInfo, +) + +LOG = logging.getLogger(__name__) +console = Console() + + +class StorageBackendService: + """Service layer for storage backend operations.""" + + def __init__(self, deployment: Deployment): + self.deployment = deployment + self.model = self._get_model_name() + # Use a consistent config key for all storage backends + self._tfvar_config_key = "TerraformVarsStorageBackends" + + def _get_model_name(self) -> str: + """Get the OpenStack machines model name.""" + model = self.deployment.openstack_machines_model + if not model.startswith("admin/"): + model = f"admin/{model}" + return model + + def list_backends(self) -> List[StorageBackendInfo]: + """List all Terraform-managed storage backends with dynamic status. + + Returns: + List of StorageBackendInfo objects for all Terraform-managed + storage backends with real-time status and charm information + """ + backends = [] + client = self.deployment.get_client() + jhelper = JujuHelper(self.deployment.juju_controller) + + # Get all available backend types from registry + from .registry import StorageBackendRegistry + + registry = StorageBackendRegistry() + available_backends = registry.get_backends() + + # Search each backend type's individual config key + for backend_type, backend_instance in available_backends.items(): + try: + # Each backend stores config in its + # own key: TerraformVarsStorageBackends{Type} + backend_config_key = backend_instance.tfvar_config_key + LOG.debug( + f"Searching for {backend_type} backends in config key: " + f"{backend_config_key}" + ) + + current_config = read_config(client, backend_config_key) + + # Look for backend-specific keys (e.g., "hitachi_backends", + # "purestorage_backends") + backend_key = f"{backend_type}_backends" + + if backend_key in current_config: + for backend_name, backend_config in current_config[ + backend_key + ].items(): + try: + # Get actual application name from Terraform config + app_name = backend_config.get( + "application_name", backend_name + ) + + # Query actual status and charm from Juju + status = self._get_application_status(jhelper, app_name) + charm_name = self._get_application_charm(jhelper, app_name) + + backend = StorageBackendInfo( + name=backend_name, + backend_type=backend_type, + status=status, + charm=charm_name, + config=backend_config.get("charm_config", {}), + ) + backends.append(backend) + LOG.debug(f"Found {backend_type} backend: {backend_name}") + except Exception as e: + LOG.warning( + f"Error processing {backend_type}" + f"backend {backend_name}: {e}" + ) + continue + else: + LOG.debug(f"No {backend_key} found in config for {backend_type}") + + except ConfigItemNotFoundException: + LOG.debug(f"No configuration found for {backend_type} backends") + continue + except Exception as e: + LOG.warning(f"Error reading {backend_type} backends from clusterd: {e}") + continue + + return backends + + def _get_application_status(self, jhelper: JujuHelper, app_name: str) -> str: + """Get application status from Juju. + + Args: + jhelper: JujuHelper instance for Juju operations + app_name: Name of the Juju application + + Returns: + Application status string or "unknown" if not found + """ + try: + # Get model status using JujuHelper.get_model_status() + model_status = jhelper.get_model_status( + self.deployment.openstack_machines_model + ) + + # Check if application exists in the model + if app_name in model_status.apps: + app_status = model_status.apps[app_name] + return app_status.app_status.current + + return "not-found" + except Exception as e: + LOG.debug(f"Failed to get status for application {app_name}: {e}") + return "unknown" + + def _get_application_charm(self, jhelper: JujuHelper, app_name: str) -> str: + """Get charm name from Juju. + + Args: + jhelper: JujuHelper instance for Juju operations + app_name: Name of the Juju application + + Returns: + Charm name or fallback name if not found + """ + try: + # Get model status using JujuHelper.get_model_status() + model_status = jhelper.get_model_status( + self.deployment.openstack_machines_model + ) + + # Check if application exists in the model + if app_name in model_status.apps: + app_status = model_status.apps[app_name] + charm_url = app_status.charm + return charm_url + + return "Not Found" + + except Exception as e: + LOG.debug(f"Failed to get charm for application {app_name}: {e}") + return "Unknown" + + def _load_backend_tfvars(self, backend_type: str) -> Dict[str, Any]: + """Safely load storage backend Terraform variables from clusterd. + + Args: + backend_type: The backend type (e.g., 'hitachi', 'purestorage') + + Returns: + Dict containing the backend configuration, empty dict if not found. + """ + try: + # Get the backend instance to access its config key + from .registry import StorageBackendRegistry + + registry = StorageBackendRegistry() + available_backends = registry.get_backends() + + if backend_type not in available_backends: + return {} + + backend_instance = available_backends[backend_type] + backend_config_key = backend_instance.tfvar_config_key + + client = self.deployment.get_client() + return read_config(client, backend_config_key) + except ConfigItemNotFoundException: + return {} + except Exception: + return {} + + def _iter_backend_items(self, tfvars: Dict[str, Any], backend_type: str): + """Yield (name, config) pairs for a given backend_type.""" + typed_key = f"{backend_type}_backends" + typed_map = tfvars.get(typed_key, {}) or {} + if isinstance(typed_map, dict): + for name, cfg in typed_map.items(): + yield name, cfg + + def _get_backend_entry( + self, backend_type: str, backend_name: str + ) -> Dict[str, Any] | None: + """Return the backend config entry if found, else None.""" + tfvars = self._load_backend_tfvars(backend_type) + for name, cfg in self._iter_backend_items(tfvars, backend_type): + if name == backend_name: + return cfg + return None + + def backend_exists(self, backend_name: str, backend_type: str) -> bool: + """Check if a backend exists in Terraform configuration.""" + return self._get_backend_entry(backend_type, backend_name) is not None + + def get_backend_config( + self, backend_name: str, backend_type: str + ) -> Dict[str, Any]: + """Get the current configuration of a storage backend.""" + try: + entry = self._get_backend_entry(backend_type, backend_name) + if not entry: + raise BackendNotFoundException(f"Backend '{backend_name}' not found") + + # Return the full backend configuration, not just charm_config + # This includes credentials that can be masked by the display logic + full_config = dict(entry) + + # Merge charm_config into the top level for backward compatibility + charm_config = entry.get("charm_config", {}) + full_config.update(charm_config) + + # Remove the charm_config key to avoid showing it as a separate field + full_config.pop("charm_config", None) + + return full_config + + except BackendNotFoundException: + raise + except Exception as e: + LOG.error(f"Failed to get config for backend '{backend_name}': {e}") + raise StorageBackendException(f"Failed to get backend config: {e}") from e diff --git a/sunbeam-python/sunbeam/storage/steps.py b/sunbeam-python/sunbeam/storage/steps.py new file mode 100644 index 000000000..2360cda3c --- /dev/null +++ b/sunbeam-python/sunbeam/storage/steps.py @@ -0,0 +1,642 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Base step classes for storage backend implementations. + +This module provides base step classes that facilitate the implementation +of storage backend steps. Backends can inherit from these base classes +to get common functionality while customizing specific behavior. +""" + +import logging +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Dict + +import tenacity +from rich.console import Console +from rich.status import Status + +from sunbeam.clusterd.client import Client +from sunbeam.clusterd.service import ConfigItemNotFoundException +from sunbeam.core.common import ( + BaseStep, + Result, + ResultType, + friendly_terraform_lock_retry_callback, + read_config, + update_config, +) +from sunbeam.core.deployment import Deployment +from sunbeam.core.juju import ( + ControllerNotFoundException, + ControllerNotReachableException, + JujuException, + JujuHelper, +) +from sunbeam.core.manifest import Manifest +from sunbeam.core.terraform import TerraformHelper, TerraformStateLockedException + +from .models import BackendNotFoundException, StorageBackendConfig + +if TYPE_CHECKING: + from .base import StorageBackendBase + +LOG = logging.getLogger(__name__) +console = Console() + + +class ValidateStoragePrerequisitesStep(BaseStep): + """Validate that Sunbeam is bootstrapped and storage role is deployed.""" + + def __init__(self, deployment: Deployment, client: Client, jhelper: JujuHelper): + super().__init__( + "Validate storage prerequisites", + "Checking Sunbeam bootstrap and storage role deployment", + ) + self.deployment = deployment + self.client = client + self.jhelper = jhelper + self.OPENSTACK_MACHINE_MODEL = self.deployment.openstack_machines_model + + def _check_juju_authentication(self) -> Result: + """Check if the current user is authenticated with Juju.""" + try: + # Use the existing JujuHelper to check authentication + # If we can list models, we're authenticated + models = self.jhelper.models() + LOG.debug( + f"Juju authentication check successful, found {len(models)} models" + ) + return Result(ResultType.COMPLETED) + + except ControllerNotFoundException: + return Result( + ResultType.FAILED, + "Juju controller not found. Please ensure Sunbeam is bootstrapped:\n" + "'sunbeam cluster bootstrap'", + ) + except ControllerNotReachableException: + return Result( + ResultType.FAILED, + "Juju controller not reachable. Please check network connectivity\n" + "or re-authenticate with 'sunbeam utils juju-login'", + ) + except JujuException as e: + # Check if it's an authentication-related error + error_msg = str(e).lower() + if any( + keyword in error_msg + for keyword in [ + "not logged in", + "authentication", + "unauthorized", + "permission denied", + "please enter password", + ] + ): + return Result( + ResultType.FAILED, + "Not authenticated with Juju controller. Please run:\n" + "'sunbeam utils juju-login'\n" + "or authenticate manually with 'juju login'", + ) + else: + return Result(ResultType.FAILED, f"Juju operation failed: {e}") + except Exception as e: + return Result( + ResultType.FAILED, f"Failed to check Juju authentication: {e}" + ) + + def run(self, status: Status | None = None) -> Result: + """Validate storage backend prerequisites.""" + try: + # 0. Check Juju authentication first + auth_result = self._check_juju_authentication() + if auth_result.result_type != ResultType.COMPLETED: + return auth_result + + # 1. Check if Sunbeam is bootstrapped + is_bootstrapped = self.client.cluster.check_sunbeam_bootstrapped() + if not is_bootstrapped: + return Result( + ResultType.FAILED, + "Deployment not bootstrapped. Please run\n" + "'sunbeam cluster bootstrap' first.", + ) + + # 2. Check if OpenStack model exists + if not self.jhelper.model_exists(self.OPENSTACK_MACHINE_MODEL): + return Result( + ResultType.FAILED, + f"OpenStack model '{self.OPENSTACK_MACHINE_MODEL}' not found. " + "Please deploy OpenStack first with\n" + "'sunbeam configure --openstack'.", + ) + + # 3. Check if storage role is deployed (at least one storage node) + storage_nodes = self.client.cluster.list_nodes_by_role("storage") + if not storage_nodes: + return Result( + ResultType.FAILED, + "No storage role found. Please add storage nodes to the cluster " + "before deploying storage backends.", + ) + + # 4. Check if cinder-volume application exists in OpenStack model + try: + cinder_volume_app = self.jhelper.get_application( + "cinder-volume", self.OPENSTACK_MACHINE_MODEL + ) + if not cinder_volume_app: + return Result( + ResultType.FAILED, + "cinder-volume application not found in OpenStack model. " + "Please deploy OpenStack storage services first.", + ) + except Exception as e: + LOG.debug(f"Failed to check cinder-volume application: {e}") + return Result( + ResultType.FAILED, + "Unable to verify cinder-volume application. " + "Please ensure OpenStack storage services are deployed.", + ) + + console.print("✓ All storage prerequisites validated successfully") + return Result(ResultType.COMPLETED) + + except Exception as e: + LOG.error(f"Failed to validate storage prerequisites: {e}") + return Result(ResultType.FAILED, str(e)) + + +class BaseStorageBackendDeployStep(BaseStep, ABC): + """Base class for storage backend deployment steps. + + Provides common deployment functionality that backends can inherit from + and customize as needed. Backends should override get_terraform_variables() + and can override other methods for custom behavior. + """ + + def __init__( + self, + deployment: Deployment, + client: Client, + tfhelper: TerraformHelper, + jhelper: JujuHelper, + manifest: Manifest, + backend_name: str, + backend_config: StorageBackendConfig, + backend_instance: "StorageBackendBase", + model: str, + ): + super().__init__( + f"Deploy {backend_instance.display_name} backend {backend_name}", + f"Deploying {backend_instance.display_name} storage backend {backend_name}", + ) + self.deployment = deployment + self.client = client + self.tfhelper = tfhelper + self.jhelper = jhelper + self.manifest = manifest + self.backend_name = backend_name + self.backend_config = backend_config + self.backend_instance = backend_instance + self.model = model + + @abstractmethod + def get_terraform_variables(self) -> Dict[str, Any]: + """Get Terraform variables for this backend deployment. + + Backends must implement this method to provide their specific + Terraform variables for deployment. + """ + pass + + @tenacity.retry( + wait=tenacity.wait_fixed(60), + stop=tenacity.stop_after_delay(300), + retry=tenacity.retry_if_exception_type(TerraformStateLockedException), + retry_error_callback=friendly_terraform_lock_retry_callback, + before_sleep=lambda retry_state: console.print( + f"Terraform state locked, retrying in 60 seconds... " + f"(attempt {retry_state.attempt_number}/5)" + ), + ) + def run(self, status: Status | None = None) -> Result: + """Deploy the storage backend using Terraform.""" + try: + # Ensure fresh Juju credentials and Terraform env before applying + try: + self.deployment.reload_tfhelpers() + except Exception as cred_err: + LOG.debug(f"Failed to reload credentials/env: {cred_err}") + + # Get Terraform variables for this backend (contains a single backend entry) + tf_vars = self.get_terraform_variables() + + # Merge with existing backends so we don't overwrite them + backend_key = f"{self.backend_instance.name}_backends" + try: + current_tfvars = read_config( + self.client, self.backend_instance.tfvar_config_key + ) + current_backends = ( + current_tfvars.get(backend_key, {}) if current_tfvars else {} + ) + except Exception: + current_backends = {} + + # The new backend map is at tf_vars[backend_key] + new_backends = tf_vars.get(backend_key, {}) + merged_backends = {**current_backends, **new_backends} + tf_vars[backend_key] = merged_backends + + # Update Terraform variables and apply with merged map + self.tfhelper.update_tfvars_and_apply_tf( + self.client, + self.manifest, + tfvar_config=self.backend_instance.tfvar_config_key, + override_tfvars=tf_vars, + ) + + console.print( + f"Successfully deployed {self.backend_instance.display_name} " + f"backend '{self.backend_name}'" + ) + return Result(ResultType.COMPLETED) + + except TerraformStateLockedException as e: + # Bubble up to trigger retry + raise e + except Exception as e: + LOG.error( + f"Failed to deploy {self.backend_instance.display_name} " + f"backend {self.backend_name}: {e}" + ) + return Result(ResultType.FAILED, str(e)) + + def get_application_timeout(self) -> int: + """Return application timeout in seconds. Override for custom timeout.""" + return 1200 # 20 minutes, same as cinder-volume + + def get_accepted_application_status(self) -> list[str]: + """Return accepted application status.""" + return ["active", "waiting"] + + +class BaseStorageBackendDestroyStep(BaseStep, ABC): + """Base class for storage backend destruction steps. + + Provides common destruction functionality that backends can inherit from + and customize as needed. Handles Terraform state cleanup and configuration + removal from clusterd. + """ + + def __init__( + self, + deployment: Deployment, + client: Client, + tfhelper: TerraformHelper, + jhelper: JujuHelper, + manifest: Manifest, + backend_name: str, + backend_instance: "StorageBackendBase", + model: str, + ): + super().__init__( + f"Destroy {backend_instance.display_name} backend {backend_name}", + f"Destroying {backend_instance.display_name} storage " + f"backend {backend_name}", + ) + self.deployment = deployment + self.client = client + self.tfhelper = tfhelper + self.jhelper = jhelper + self.manifest = manifest + self.backend_name = backend_name + self.backend_instance = backend_instance + self.model = model + + def should_destroy_all_resources(self) -> bool: + """Check if all resources should be destroyed (no backends left). + + Override this method if backend has custom logic for determining + when to destroy all resources vs just removing configuration. + """ + try: + current_config = read_config( + self.client, self.backend_instance.tfvar_config_key + ) + + backend_key = ( + f"{self.backend_instance.name}_backends" # e.g., "hitachi_backends" + ) + + if backend_key in current_config: + backends = current_config[backend_key] + else: + raise BackendNotFoundException( + f"Backend '{self.backend_name}' not found" + ) + + # Remove this backend from the count + backends_without_current = { + k: v for k, v in backends.items() if k != self.backend_name + } + return len(backends_without_current) == 0 + except ConfigItemNotFoundException: + return True + + @tenacity.retry( + wait=tenacity.wait_fixed(60), + stop=tenacity.stop_after_delay(300), + retry=tenacity.retry_if_exception_type(TerraformStateLockedException), + retry_error_callback=friendly_terraform_lock_retry_callback, + before_sleep=lambda retry_state: console.print( + f"Terraform state locked, retrying in 60 seconds... " + f"(attempt {retry_state.attempt_number}/5)" + ), + ) + def run(self, status: Status | None = None) -> Result: + """Run the destroy step atomically. + + This step removes the backend from the Terraform configuration + and applies the changes to destroy the associated resources. + The operation is atomic: either it succeeds completely or fails + without modifying the configuration. + """ + try: + # Ensure fresh Juju credentials and Terraform env before destroying/applying + try: + self.deployment.reload_tfhelpers() + except Exception as cred_err: + LOG.debug(f"Failed to reload credentials/env: {cred_err}") + + # First, read and validate the current configuration + try: + current_config = read_config( + self.client, self.backend_instance.tfvar_config_key + ) + except ConfigItemNotFoundException: + LOG.warning(f"No configuration found for backend {self.backend_name}") + raise BackendNotFoundException( + f"No Terraform configuration found for backend " + f"'{self.backend_name}'" + ) + + # Check if backend exists in configuration + backend_key = ( + f"{self.backend_instance.name}_backends" # e.g., "hitachi_backends" + ) + + if ( + backend_key not in current_config + or self.backend_name not in current_config[backend_key] + ): + LOG.warning(f"Backend {self.backend_name} not found in configuration") + raise BackendNotFoundException( + f"Backend '{self.backend_name}' not found in Terraform" + f"configuration. This may indicate a state inconsistency." + ) + + # Create a backup of the backend configuration before removal + backend_backup = current_config[backend_key][self.backend_name].copy() + + # Remove backend from configuration (in memory only) + del current_config[backend_key][self.backend_name] + + # Determine if we need to destroy all resources or just apply changes + destroy_all = self.should_destroy_all_resources() + + try: + if destroy_all: + # For complete destruction: first update config, then destroy + # If destroy fails, we can restore the config + update_config( + self.client, + self.backend_instance.tfvar_config_key, + current_config, + ) + + try: + self.tfhelper.destroy() + except Exception as destroy_error: + # Restore the backend configuration if destroy fails + LOG.error( + f"""Terraform destroy failed, + restoring configuration: {destroy_error}""" + ) + current_config[backend_key][self.backend_name] = backend_backup + update_config( + self.client, + self.backend_instance.tfvar_config_key, + current_config, + ) + raise destroy_error + else: + # For partial removal: update config and apply atomically + LOG.info( + f"Performing partial removal for backend {self.backend_name}" + ) + LOG.info( + f"Remaining backends after removal: " + f"{list(current_config[backend_key].keys())}" + ) + + # First update the configuration + update_config( + self.client, + self.backend_instance.tfvar_config_key, + current_config, + ) + LOG.info("Configuration updated, now running terraform apply...") + + try: + LOG.info("Starting terraform apply for partial removal") + # CRITICAL: Write the updated Terraform variables + # before applying + # This was missing and causing partial removal to fail! + LOG.info("Writing updated Terraform variables...") + + # Get the updated Terraform variables from the current config + tf_vars = current_config.copy() + LOG.info( + f"Writing Terraform variables with backends: " + f"{list(tf_vars.get(backend_key, {}).keys())}" + ) + + self.tfhelper.write_tfvars(tf_vars) + LOG.info("Terraform variables written, now applying...") + self.tfhelper.apply() + LOG.info( + "Terraform apply completed successfully for partial removal" + ) + except Exception as apply_error: + # Restore the backend configuration if apply fails + LOG.error( + f"Terraform apply failed, restoring configuration: " + f"{apply_error}" + ) + current_config[backend_key][self.backend_name] = backend_backup + update_config( + self.client, + self.backend_instance.tfvar_config_key, + current_config, + ) + raise apply_error + + except Exception as tf_error: + # Any Terraform operation failure should be propagated + raise tf_error + + console.print( + f"Successfully removed {self.backend_instance.display_name} " + f"backend '{self.backend_name}'" + ) + return Result(ResultType.COMPLETED) + + except TerraformStateLockedException as e: + # Bubble up to trigger retry + raise e + except Exception as e: + LOG.error( + f"Failed to destroy {self.backend_instance.display_name} " + f"backend {self.backend_name}: {e}" + ) + return Result(ResultType.FAILED, str(e)) + + def get_application_timeout(self) -> int: + """Return application timeout in seconds.""" + return 1200 # 20 minutes, same as cinder-volume + + +class BaseStorageBackendConfigUpdateStep(BaseStep, ABC): + """Base class for storage backend configuration update steps. + + Provides common configuration update functionality that backends can inherit from + and customize as needed. Handles configuration updates and reset operations. + """ + + def __init__( + self, + deployment: Deployment, + backend_instance: "StorageBackendBase", + backend_name: str, + config_updates: Dict[str, Any], + ): + super().__init__( + f"Update {backend_instance.display_name} backend config {backend_name}", + f"Updating {backend_instance.display_name} storage backend " + f"configuration for {backend_name}", + ) + self.deployment = deployment + self.backend_instance = backend_instance + self.backend_name = backend_name + self.config_updates = config_updates + self.client = deployment.get_client() + self.tfhelper = deployment.get_tfhelper(backend_instance.tfplan) + + def handle_update_operation(self, current_config: Dict[str, Any]) -> Dict[str, Any]: + """Handle configuration update operation. Override for custom update logic. + + Args: + current_config: Current backend configuration + + Returns: + Updated configuration with new values applied + """ + # Get backend config from new format only + backend_key = ( + f"{self.backend_instance.name}_backends" # e.g., "hitachi_backends" + ) + backend_config = current_config[backend_key][self.backend_name] + if "charm_config" not in backend_config: + backend_config["charm_config"] = {} + + # Apply configuration updates (excluding reset keys) with field mapping + updates = {k: v for k, v in self.config_updates.items() if k != "_reset_keys"} + + # Apply field mapping to convert internal field names to charm field names + field_mapping = self.backend_instance.get_field_mapping() + mapped_updates = {} + for key, value in updates.items(): + # Use field mapping if available, otherwise use the key as-is + charm_key = field_mapping.get(key, key) + mapped_updates[charm_key] = value + + backend_config["charm_config"].update(mapped_updates) + + return current_config + + @tenacity.retry( + wait=tenacity.wait_fixed(60), + stop=tenacity.stop_after_delay(300), + retry=tenacity.retry_if_exception_type(TerraformStateLockedException), + retry_error_callback=friendly_terraform_lock_retry_callback, + before_sleep=lambda retry_state: console.print( + f"Terraform state locked, retrying in 60 seconds... " + f"(attempt {retry_state.attempt_number}/5)" + ), + ) + def run(self, status: Status | None = None) -> Result: + """Update the storage backend configuration using Terraform.""" + # Read current configuration + try: + current_config = read_config( + self.client, self.backend_instance.tfvar_config_key + ) + + # Check new format (backend-specific keys only) + backend_key = ( + f"{self.backend_instance.name}_backends" # e.g., "hitachi_backends" + ) + + if ( + backend_key not in current_config + or self.backend_name not in current_config[backend_key] + ): + return Result( + ResultType.FAILED, f"Backend {self.backend_name} not found" + ) + + # Handle update operation + current_config = self.handle_update_operation(current_config) + operation_type = "update" + + # Save updated configuration and apply with updated tfvars + update_config( + self.client, self.backend_instance.tfvar_config_key, current_config + ) + + # Ensure fresh Juju credentials and Terraform env before applying + try: + self.deployment.reload_tfhelpers() + except Exception as cred_err: + LOG.debug(f"Failed to reload credentials/env: {cred_err}") + + # Write the updated tfvars and apply + self.tfhelper.write_tfvars(current_config) + self.tfhelper.apply() + + console.print( + f"Successfully {operation_type}d " + f"{self.backend_instance.display_name} backend " + f"'{self.backend_name}' configuration" + ) + return Result(ResultType.COMPLETED) + + except ConfigItemNotFoundException: + return Result( + ResultType.FAILED, + f"Configuration not found for backend {self.backend_name}", + ) + + except TerraformStateLockedException as e: + # Bubble up to trigger retry + raise e + except Exception as e: + LOG.error( + f"Failed to update {self.backend_instance.display_name} " + f"backend {self.backend_name} configuration: {e}" + ) + return Result(ResultType.FAILED, str(e)) diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/__init__.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/__init__.py new file mode 100644 index 000000000..9c90cc6d9 --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for storage backends.""" diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/hitachi/__init__.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/hitachi/__init__.py new file mode 100644 index 000000000..f2acd69a0 --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/hitachi/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for Hitachi storage backend.""" diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/hitachi/test_cli.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/hitachi/test_cli.py new file mode 100644 index 000000000..238e15718 --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/hitachi/test_cli.py @@ -0,0 +1,367 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for Hitachi storage backend CLI commands.""" + +import unittest.mock as mock + +import click +from click.testing import CliRunner + +from sunbeam.storage.backends.hitachi.backend import HitachiBackend, HitachiConfig +from sunbeam.storage.backends.hitachi.cli import HitachiCLI + + +class TestHitachiCLI: + """Test cases for Hitachi storage backend CLI commands.""" + + def setup_method(self): + """Set up test fixtures.""" + self.runner = CliRunner() + self.mock_deployment = mock.MagicMock() + self.backend = HitachiBackend() + self.cli = HitachiCLI(self.backend) + + def test_register_add_cli(self): + """Test that register_add_cli creates a command properly.""" + mock_add_group = mock.MagicMock() + + # Test that register_add_cli doesn't raise an exception + self.cli.register_add_cli(mock_add_group) + + # Verify that a command was added to the group + mock_add_group.add_command.assert_called_once() + + # Get the command that was added + added_command = mock_add_group.add_command.call_args[0][0] + assert isinstance(added_command, click.Command) + assert added_command.name == "hitachi" + + def test_load_config_file_yaml(self): + """Test loading YAML config file.""" + import tempfile + + import yaml + + config_data = { + "name": "test-backend", + "hitachi_storage_id": "12345", + "san_ip": "192.168.1.100", + } + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(config_data, f) + f.flush() + + from pathlib import Path + + result = self.cli._load_config_file(Path(f.name)) + + assert result == config_data + + def test_add_missing_required(self): + """Test that Click exits non-zero with missing required option.""" + # Create a mock add group and register the command + add_group = click.Group(name="add") + self.cli.register_add_cli(add_group) + + result = self.runner.invoke( + add_group, + [ + "hitachi", + "--name", + "test-hitachi", + # Missing required --hitachi-storage-id + "--hitachi-pools", + "pool1,pool2", + "--san-ip", + "192.168.1.100", + ], + obj=self.mock_deployment, + ) + + assert result.exit_code != 0 + assert "Missing option" in result.output or "required" in result.output.lower() + + def test_register_cli(self): + """Test that register_cli creates management commands properly.""" + mock_remove = mock.MagicMock() + mock_config_show = mock.MagicMock() + mock_config_set = mock.MagicMock() + mock_config_options = mock.MagicMock() + + # Test that register_cli doesn't raise an exception + self.cli.register_cli( + mock_remove, + mock_config_show, + mock_config_set, + mock_config_options, + self.mock_deployment, + ) + + # Verify that commands were added to each group + mock_remove.add_command.assert_called_once() + mock_config_show.add_command.assert_called_once() + mock_config_set.add_command.assert_called_once() + mock_config_options.add_command.assert_called_once() + + def test_cli_methods_exist(self): + """Test that all required CLI methods exist.""" + assert hasattr(self.cli, "register_add_cli") + assert hasattr(self.cli, "register_cli") + assert hasattr(self.cli, "_load_config_file") + + # Test they are callable + assert callable(self.cli.register_add_cli) + assert callable(self.cli.register_cli) + assert callable(self.cli._load_config_file) + + def test_load_config_file_json(self): + """Test loading JSON config file.""" + import json + import tempfile + + config_data = { + "name": "test-backend", + "hitachi_storage_id": "12345", + "san_ip": "192.168.1.100", + } + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(config_data, f) + f.flush() + + from pathlib import Path + + result = self.cli._load_config_file(Path(f.name)) + + assert result == config_data + + def test_load_config_file_empty_path(self): + """Test loading config file with empty path returns empty dict.""" + result = self.cli._load_config_file(None) + assert result == {} + + def test_cli_backend_reference(self): + """Test that CLI maintains reference to backend.""" + assert self.cli.backend is not None + assert isinstance(self.cli.backend, HitachiBackend) + assert self.cli.backend.name == "hitachi" + + def test_add_with_chap_auth(self): + """Test addition with CHAP authentication enabled.""" + with mock.patch.object(self.backend, "add_backend") as mock_add_backend: + mock_add_backend.return_value = None + + # Create a mock add group and register the command + add_group = click.Group(name="add") + self.cli.register_add_cli(add_group) + + result = self.runner.invoke( + add_group, + [ + "hitachi", + "--name", + "test-hitachi", + "--hitachi-storage-id", + "12345", + "--hitachi-pools", + "pool1,pool2", + "--san-ip", + "192.168.1.100", + "--san-username", + "sanuser", + "--san-password", + "sanpass", + "--protocol", + "iSCSI", + "--chap-username", + "chapuser", + "--chap-password", + "chappass", + "--use-chap-auth", + "true", + ], + obj=self.mock_deployment, + ) + + assert result.exit_code == 0 + mock_add_backend.assert_called_once() + + # Verify the config passed to add_backend + call_args = mock_add_backend.call_args + config = call_args[0][2] # Third argument is the config + assert config.use_chap_auth is True + assert config.chap_username == "chapuser" + assert config.chap_password == "chappass" + + def test_add_backend_exception(self): + """Test that backend exceptions are handled gracefully.""" + with mock.patch.object(self.backend, "add_backend") as mock_add_backend: + mock_add_backend.side_effect = Exception("Backend error") + + # Create a mock add group and register the command + add_group = click.Group(name="add") + self.cli.register_add_cli(add_group) + + result = self.runner.invoke( + add_group, + [ + "hitachi", + "--name", + "test-hitachi", + "--hitachi-storage-id", + "12345", + "--hitachi-pools", + "pool1,pool2", + "--san-ip", + "192.168.1.100", + "--san-username", + "sanuser", + "--san-password", + "sanpass", + "--protocol", + "iSCSI", + ], + obj=self.mock_deployment, + ) + + assert result.exit_code != 0 + # The exception is raised but not caught by CLI, so no output is generated + # The exception object itself contains the error message + assert result.exception is not None + assert "Backend error" in str(result.exception) + + def test_cli_initialization(self): + """Test that HitachiCLI can be initialized with a backend.""" + backend = HitachiBackend() + cli = HitachiCLI(backend) + assert cli.backend == backend + + def test_backend_delegation_works(self): + """Test that CLI properly delegates to backend methods.""" + # Test that CLI can access backend properties + assert self.cli.backend.name == "hitachi" + assert self.cli.backend.display_name == "Hitachi VSP Storage" + assert self.cli.backend.config_class == HitachiConfig + + def test_register_add_cli_creates_command_with_correct_name(self): + """Test that register_add_cli creates a command with the backend name.""" + mock_add_group = mock.MagicMock() + + self.cli.register_add_cli(mock_add_group) + + # Verify command was added with correct name + mock_add_group.add_command.assert_called_once() + added_command = mock_add_group.add_command.call_args[0][0] + assert added_command.name == self.backend.name + + def test_register_cli_creates_all_management_commands(self): + """Test that register_cli creates all expected management commands.""" + mock_remove = mock.MagicMock() + mock_config_show = mock.MagicMock() + mock_config_set = mock.MagicMock() + mock_config_options = mock.MagicMock() + + self.cli.register_cli( + mock_remove, + mock_config_show, + mock_config_set, + mock_config_options, + self.mock_deployment, + ) + + # Verify all commands were created with correct names + for mock_group in [ + mock_remove, + mock_config_show, + mock_config_set, + mock_config_options, + ]: + mock_group.add_command.assert_called_once() + added_command = mock_group.add_command.call_args[0][0] + assert added_command.name == self.backend.name + + def test_remove_ok(self): + """Test successful removal of Hitachi backend.""" + with ( + mock.patch.object(self.backend, "_get_service") as mock_get_service, + mock.patch.object(self.backend, "remove_backend") as mock_remove_backend, + ): + mock_service = mock.MagicMock() + mock_service.backend_exists.return_value = True + mock_get_service.return_value = mock_service + mock_remove_backend.return_value = None + + # Create a mock remove group and register the command + remove_group = click.Group(name="remove") + config_show = click.Group(name="config_show") + config_set = click.Group(name="config_set") + config_options = click.Group(name="config_options") + self.cli.register_cli( + remove_group, + config_show, + config_set, + config_options, + self.mock_deployment, + ) + + result = self.runner.invoke( + remove_group, + ["hitachi", "test-hitachi", "--yes"], + obj=self.mock_deployment, + ) + + assert result.exit_code == 0 + mock_get_service.assert_called_once_with(self.mock_deployment) + mock_service.backend_exists.assert_called_once_with( + "test-hitachi", "hitachi" + ) + mock_remove_backend.assert_called_once_with( + self.mock_deployment, "test-hitachi", mock.ANY + ) + + def test_remove_service_exception(self): + """Test handling of service exceptions during removal.""" + with ( + mock.patch.object(self.backend, "_get_service") as mock_get_service, + mock.patch.object(self.backend, "remove_backend") as mock_remove_backend, + ): + mock_service = mock.MagicMock() + mock_service.backend_exists.return_value = True + mock_get_service.return_value = mock_service + mock_remove_backend.side_effect = Exception("Service error") + + # Create a mock remove group and register the command + remove_group = click.Group(name="remove") + config_show = click.Group(name="config_show") + config_set = click.Group(name="config_set") + config_options = click.Group(name="config_options") + self.cli.register_cli( + remove_group, + config_show, + config_set, + config_options, + self.mock_deployment, + ) + + result = self.runner.invoke( + remove_group, + ["hitachi", "test-hitachi", "--yes"], + obj=self.mock_deployment, + ) + + assert result.exit_code != 0 + assert "Service" in result.output and "error" in result.output + + def test_cli_class_has_required_methods(self): + """Test that HitachiCLI class has all required methods.""" + required_methods = [ + "register_add_cli", + "register_cli", + "_load_config_file", + ] + + for method_name in required_methods: + assert hasattr(self.cli, method_name) + assert callable(getattr(self.cli, method_name)) diff --git a/sunbeam-python/tests/unit/sunbeam/storage/conftest.py b/sunbeam-python/tests/unit/sunbeam/storage/conftest.py new file mode 100644 index 000000000..00b2c0996 --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/conftest.py @@ -0,0 +1,275 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Test fixtures for storage backend tests.""" + +from unittest.mock import Mock, PropertyMock + +import pytest + +from sunbeam.clusterd.client import Client +from sunbeam.core.manifest import Manifest +from sunbeam.core.terraform import TerraformHelper +from sunbeam.storage.base import StorageBackendBase +from sunbeam.storage.models import StorageBackendConfig + + +@pytest.fixture +def mock_deployment(): + """Mock deployment object.""" + deployment = Mock() + deployment.openstack_machines_model = "openstack" # Match actual service behavior + + # Mock the client with cluster attribute + mock_client = Mock() + mock_cluster = Mock() + mock_client.cluster = mock_cluster + deployment.get_client.return_value = mock_client + + # Mock juju_controller + mock_controller = Mock() + mock_controller.name = "test-controller" + type(deployment).juju_controller = PropertyMock(return_value=mock_controller) + + # Mock get_tfhelper + deployment.get_tfhelper.return_value = Mock(spec=TerraformHelper) + + return deployment + + +@pytest.fixture +def mock_client(): + """Mock clusterd client.""" + client = Mock(spec=Client) + client.cluster = Mock() + return client + + +@pytest.fixture +def mock_tfhelper(): + """Mock Terraform helper.""" + return Mock(spec=TerraformHelper) + + +@pytest.fixture +def mock_jhelper(): + """Mock Juju helper.""" + return Mock() + + +@pytest.fixture +def mock_manifest(): + """Mock manifest object.""" + return Mock(spec=Manifest) + + +@pytest.fixture +def sample_backend_config(): + """Sample backend configuration for testing.""" + return { + "model": "openstack", + "hitachi_backends": { + "test-backend": { + "backend_type": "hitachi", + "charm_name": "cinder-volume-hitachi", + "charm_channel": "latest/edge", + "backend_config": { + "hitachi-storage-id": "123456", + "hitachi-pools": ["pool1", "pool2"], + "san-ip": "192.168.1.100", + }, + "backend_endpoint": "cinder-volume", + "units": 1, + "additional_integrations": {}, + } + }, + } + + +@pytest.fixture +def sample_clusterd_config(): + """Sample clusterd configuration for testing.""" + return { + "TerraformVarsStorageBackends": { + "hitachi_backends": { + "test-backend": { + "backend_type": "hitachi", + "charm_name": "cinder-volume-hitachi", + "charm_channel": "latest/edge", + "charm_config": { + "hitachi-storage-id": "123456", + "hitachi-pools": ["pool1", "pool2"], + "san-ip": "192.168.1.100", + }, + "backend_endpoint": "cinder-volume", + "units": 1, + "additional_integrations": {}, + } + } + } + } + + +@pytest.fixture +def mock_storage_backend(): + """Mock storage backend for testing.""" + + class MockStorageBackend(StorageBackendBase): + """Mock storage backend for testing.""" + + name = "mock" + display_name = "Mock Storage Backend" + charm_name = "mock-charm" + + def __init__(self): + super().__init__() + self.tfplan = "mock-backend-plan" + self.tfplan_dir = "deploy-mock-backend" + + @property + def config_class(self): + return StorageBackendConfig + + def get_terraform_variables( + self, backend_name: str, config: StorageBackendConfig, model: str + ): + return { + "model": model, + "mock_backends": { + backend_name: { + "backend_type": self.name, + "charm_name": self.charm_name, + "charm_channel": "latest/stable", + "backend_config": config.model_dump(), + "backend_endpoint": "storage-backend", + "units": 1, + "additional_integrations": {}, + } + }, + } + + def create_deploy_step( + self, + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + backend_config, + model, + ): + """Create a mock deploy step.""" + from sunbeam.storage.steps import BaseStorageBackendDeployStep + + class MockDeployStep(BaseStorageBackendDeployStep): + def get_terraform_variables(self): + return self.backend_instance.get_terraform_variables( + self.backend_name, self.backend_config, self.model + ) + + return MockDeployStep( + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + backend_config, + self, + model, + ) + + def create_destroy_step( + self, deployment, client, tfhelper, jhelper, manifest, backend_name, model + ): + """Create a mock destroy step.""" + from sunbeam.storage.steps import BaseStorageBackendDestroyStep + + return BaseStorageBackendDestroyStep( + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + self, + model, + ) + + def create_config_update_step( + self, + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + backend_config, + model, + updates, + ): + """Create a mock config update step.""" + from sunbeam.storage.steps import BaseStorageBackendConfigUpdateStep + + return BaseStorageBackendConfigUpdateStep( + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + backend_config, + self, + model, + updates, + ) + + def create_update_config_step(self, deployment, backend_name, config_updates): + """Create a configuration update step for this backend.""" + # Mock implementation - return a simple mock step + + from sunbeam.core.common import BaseStep + + class MockUpdateConfigStep(BaseStep): + def run(self): + from sunbeam.core.common import ResultType + + return ResultType.COMPLETED + + return MockUpdateConfigStep() + + def commands(self): + """Return mock commands for testing.""" + return { + "add": [{"name": "mock", "command": Mock()}], + "remove": [{"name": "mock", "command": Mock()}], + "list": [{"name": "mock", "command": Mock()}], + "config": [{"name": "mock", "command": Mock()}], + } + + def register_add_cli(self, add): + """Mock CLI registration.""" + pass + + def register_cli( + self, + remove, + config_show, + config_set, + config_reset, + config_options, + deployment, + ): + """Mock CLI registration.""" + pass + + def prompt_for_config(self, backend_name: str) -> StorageBackendConfig: + """Mock prompt for configuration.""" + return StorageBackendConfig(name=backend_name) + + def _get_default_config(self) -> StorageBackendConfig: + """Get a default configuration instance for comparison.""" + return StorageBackendConfig(name="default") + + return MockStorageBackend() diff --git a/sunbeam-python/tests/unit/sunbeam/storage/test_hitachi.py b/sunbeam-python/tests/unit/sunbeam/storage/test_hitachi.py new file mode 100644 index 000000000..d200a2e82 --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/test_hitachi.py @@ -0,0 +1,575 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for Hitachi storage backend implementation.""" + +from unittest.mock import patch + +import click +import pytest +from pydantic import ValidationError + +from sunbeam.storage.backends.hitachi.backend import ( + HitachiBackend, + HitachiConfig, + HitachiDeployStep, + HitachiDestroyStep, + HitachiUpdateConfigStep, +) +from sunbeam.storage.models import StorageBackendConfig + + +class TestHitachiConfig: + """Test cases for HitachiConfig model.""" + + def test_valid_config_minimal(self): + """Test creating valid minimal Hitachi configuration.""" + config = HitachiConfig( + name="hitachi-backend-1", + hitachi_storage_id="123456", + hitachi_pools="pool1,pool2", + san_ip="192.168.1.100", + protocol="FC", + san_username="testuser", + san_password="testpassword", + ) + + assert config.name == "hitachi-backend-1" + assert config.hitachi_storage_id == "123456" + assert config.hitachi_pools == "pool1,pool2" + assert config.san_ip == "192.168.1.100" + assert config.protocol == "FC" # Default value + assert config.san_username == "testuser" + assert config.san_password == "testpassword" + + def test_valid_config_full(self): + """Test creating valid full Hitachi configuration.""" + config = HitachiConfig( + name="hitachi-backend-1", + hitachi_storage_id="123456", + hitachi_pools="pool1,pool2", + san_ip="192.168.1.100", + protocol="iSCSI", + san_username="testuser", + san_password="testpassword", + ) + + assert config.name == "hitachi-backend-1" + assert config.hitachi_storage_id == "123456" + assert config.hitachi_pools == "pool1,pool2" + assert config.san_ip == "192.168.1.100" + assert config.protocol == "iSCSI" + assert config.san_username == "testuser" + assert config.san_password == "testpassword" + + def test_config_with_iscsi_protocol(self): + """Test configuration with iSCSI protocol.""" + config = HitachiConfig( + name="hitachi-iscsi", + hitachi_storage_id="123456", + hitachi_pools="pool1", + san_ip="192.168.1.100", + protocol="iSCSI", + san_username="testuser", + san_password="testpassword", + ) + + assert config.protocol == "iSCSI" + assert config.san_username == "testuser" + assert config.san_password == "testpassword" + + def test_config_validation_missing_required_fields(self): + """Test validation errors for missing required fields.""" + # Missing hitachi_storage_id + with pytest.raises(ValidationError): + HitachiConfig(name="test", hitachi_pools="pool1", san_ip="192.168.1.100") + + # Missing hitachi_pools + with pytest.raises(ValidationError): + HitachiConfig( + name="test", hitachi_storage_id="123456", san_ip="192.168.1.100" + ) + + # Missing san_ip + with pytest.raises(ValidationError): + HitachiConfig( + name="test", hitachi_storage_id="123456", hitachi_pools="pool1" + ) + + def test_ip_validation_valid_ip(self): + """Test valid IP address validation.""" + config = HitachiConfig( + name="test", + hitachi_storage_id="123456", + hitachi_pools="pool1", + san_ip="192.168.1.100", + protocol="FC", + san_username="testuser", + san_password="testpassword", + ) + assert config.san_ip == "192.168.1.100" + + def test_ip_validation_valid_fqdn(self): + """Test valid FQDN validation.""" + config = HitachiConfig( + name="test", + hitachi_storage_id="123456", + hitachi_pools="pool1", + san_ip="storage.example.com", + protocol="FC", + san_username="testuser", + san_password="testpassword", + ) + assert config.san_ip == "storage.example.com" + + def test_protocol_validation(self): + """Test protocol field validation.""" + # Valid protocols + for protocol in ["FC", "iSCSI"]: + config = HitachiConfig( + name="test", + hitachi_storage_id="123456", + hitachi_pools="pool1", + san_ip="192.168.1.100", + protocol=protocol, + san_username="testuser", + san_password="testpassword", + ) + assert config.protocol == protocol + + def test_config_serialization(self): + """Test configuration serialization.""" + config = HitachiConfig( + name="test", + hitachi_storage_id="123456", + hitachi_pools="pool1", + san_ip="192.168.1.100", + protocol="FC", + san_username="testuser", + san_password="testpassword", + ) + + data = config.model_dump() + assert data["name"] == "test" + assert data["hitachi_storage_id"] == "123456" + assert data["hitachi_pools"] == "pool1" + assert data["san_ip"] == "192.168.1.100" + assert data["protocol"] == "FC" + assert data["san_username"] == "testuser" + assert data["san_password"] == "testpassword" + + def test_config_inheritance(self): + """Test that HitachiConfig inherits from StorageBackendConfig.""" + config = HitachiConfig( + name="test", + hitachi_storage_id="123456", + hitachi_pools="pool1", + san_ip="192.168.1.100", + protocol="FC", + san_username="testuser", + san_password="testpassword", + ) + + assert isinstance(config, StorageBackendConfig) + + +class TestHitachiBackend: + """Test cases for HitachiBackend class.""" + + def test_init(self): + """Test backend initialization.""" + backend = HitachiBackend() + + assert backend.name == "hitachi" + assert backend.display_name == "Hitachi VSP Storage" + assert backend.charm_name == "cinder-volume-hitachi" + assert backend.tfplan == "hitachi-backend-plan" + assert backend.tfplan_dir == "deploy-hitachi-backend" + assert ( + backend.charm_channel == "latest/edge" + ) # this have to be updated after the charm progress to stable + assert backend.charm_revision == 2 + assert backend.charm_base == "ubuntu@24.04" + assert backend.backend_endpoint == "cinder-volume" + assert backend.units == 1 + assert backend.additional_integrations == [] + + def test_config_class(self): + """Test configuration class retrieval.""" + backend = HitachiBackend() + config_class = backend.config_class + assert config_class == HitachiConfig + + def test_get_field_mapping(self): + """Test field mapping for charm configuration.""" + backend = HitachiBackend() + mapping = backend.get_field_mapping() + + # Test some key mappings + assert mapping["hitachi_storage_id"] == "hitachi-storage-id" + assert mapping["hitachi_pools"] == "hitachi-pools" + assert mapping["san_ip"] == "san-ip" + assert mapping["protocol"] == "protocol" + assert mapping["use_chap_auth"] == "use-chap-auth" + + def test_get_terraform_variables(self): + """Test Terraform variables generation.""" + backend = HitachiBackend() + config = HitachiConfig( + name="hitachi-backend-1", + hitachi_storage_id="123456", + hitachi_pools="pool1,pool2", + san_ip="192.168.1.100", + protocol="FC", + san_username="testuser", + san_password="testpassword", + ) + + variables = backend.get_terraform_variables( + "hitachi-backend-1", config, "openstack" + ) + + assert "machine_model" in variables + assert "hitachi_backends" in variables + assert variables["machine_model"] == "openstack" + assert "hitachi-backend-1" in variables["hitachi_backends"] + + backend_config = variables["hitachi_backends"]["hitachi-backend-1"] + assert "charm_config" in backend_config + + # Verify charm config contains the expected fields (excluding credentials) + charm_config = backend_config["charm_config"] + assert charm_config["hitachi-storage-id"] == "123456" + assert charm_config["hitachi-pools"] == "pool1,pool2" + assert charm_config["san-ip"] == "192.168.1.100" + # Protocol field is excluded when it matches default value + assert "protocol" not in charm_config + # Credentials should not be in charm config - they go in secrets + assert "san-username" not in charm_config + assert "san-password" not in charm_config + + def test_should_include_config_value(self): + """Test configuration value inclusion logic.""" + backend = HitachiBackend() + + # Non-default string value should be included + assert backend._should_include_config_value("san-ip", "192.168.1.100", "") + + # Default string value should not be included + assert not backend._should_include_config_value( + "san-login", "maintenance", "maintenance" + ) + + # Empty string should not be included + assert not backend._should_include_config_value("san-ip", "", "") + + # Non-empty list should be included + assert backend._should_include_config_value("hitachi-pools", ["pool1"], []) + + # Empty list should not be included + assert not backend._should_include_config_value("hitachi-pools", [], []) + + # None values should not be included + assert not backend._should_include_config_value("optional-field", None, None) + + def test_create_deploy_step( + self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest + ): + """Test deployment step creation.""" + backend = HitachiBackend() + config = HitachiConfig( + name="test", + hitachi_storage_id="123456", + hitachi_pools="pool1", + san_ip="192.168.1.100", + protocol="FC", + san_username="testuser", + san_password="testpassword", + ) + + step = backend.create_deploy_step( + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + "test-backend", + config, + "openstack", + ) + + assert isinstance(step, HitachiDeployStep) + assert step.backend_name == "test-backend" + assert step.backend_config == config + assert step.model == "openstack" + + def test_create_destroy_step( + self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest + ): + """Test destruction step creation.""" + backend = HitachiBackend() + + step = backend.create_destroy_step( + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + "test-backend", + "openstack", + ) + + assert isinstance(step, HitachiDestroyStep) + assert step.backend_name == "test-backend" + assert step.model == "openstack" + + def test_create_update_config_step(self, mock_deployment): + """Test configuration update step creation.""" + backend = HitachiBackend() + config_updates = {"key1": "value1", "key2": "value2"} + + step = backend.create_update_config_step( + mock_deployment, "test-backend", config_updates + ) + + assert isinstance(step, HitachiUpdateConfigStep) + assert step.backend_name == "test-backend" + assert step.config_updates == config_updates + + @patch("click.confirm") + @patch("click.prompt") + def test_prompt_for_config(self, mock_prompt, mock_confirm, mock_deployment): + """Test configuration prompting.""" + backend = HitachiBackend() + + # Mock user inputs for all prompts in _prompt_for_config + mock_prompt.side_effect = [ + "123456", # hitachi_storage_id (Array serial number) + "pool1,pool2", # hitachi_pools (Storage pools) + "FC", # protocol + "192.168.1.100", # san_ip (Management IP/FQDN) + "maintenance", # san_username (SAN username) + "secret123", # san_password (SAN password) + "test-backend", # volume_backend_name + ] + + # Mock confirmation prompts (all False to avoid optional config) + mock_confirm.side_effect = [ + False, # configure_mirror (Configure mirror/replication) + ] + + config = backend.prompt_for_config("test-backend") + + assert isinstance(config, HitachiConfig) + assert config.name == "test-backend" + assert config.hitachi_storage_id == "123456" + assert config.hitachi_pools == "pool1,pool2" + assert config.san_ip == "192.168.1.100" + # Protocol should use default value + assert config.protocol == "FC" + + def test_validate_ip_or_fqdn_valid_ip(self): + """Test IP validation with valid IP.""" + # Should not raise exception + HitachiBackend._validate_ip_or_fqdn("192.168.1.100") + + def test_validate_ip_or_fqdn_valid_fqdn(self): + """Test IP validation with valid FQDN.""" + # Should not raise exception + HitachiBackend._validate_ip_or_fqdn("storage.example.com") + + def test_validate_ip_or_fqdn_invalid(self): + """Test IP validation with invalid input.""" + with pytest.raises(click.BadParameter): + HitachiBackend._validate_ip_or_fqdn("..invalid..") + + +class TestHitachiSteps: + """Test cases for Hitachi-specific step implementations.""" + + def test_hitachi_deploy_step_init( + self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest + ): + """Test HitachiDeployStep initialization.""" + backend_instance = HitachiBackend() + backend_name = "hitachi-backend-1" + model = "openstack" + + config = HitachiConfig( + name=backend_name, + hitachi_storage_id="123456", + hitachi_pools="pool1,pool2", + san_ip="192.168.1.100", + protocol="FC", + san_username="testuser", + san_password="testpassword", + ) + + step = HitachiDeployStep( + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + backend_name, + config, + backend_instance, + model, + ) + + assert step.backend_name == backend_name + assert step.backend_config == config + assert step.backend_instance == backend_instance + assert step.model == model + + def test_hitachi_deploy_step_get_terraform_variables( + self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest + ): + """Test Terraform variables generation in deploy step.""" + backend_instance = HitachiBackend() + backend_name = "hitachi-backend-1" + model = "openstack" + + config = HitachiConfig( + name=backend_name, + hitachi_storage_id="123456", + hitachi_pools="pool1,pool2", + san_ip="192.168.1.100", + protocol="FC", + san_username="testuser", + san_password="testpassword", + ) + + step = HitachiDeployStep( + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + backend_name, + config, + backend_instance, + model, + ) + + variables = step.get_terraform_variables() + + assert "machine_model" in variables + assert "hitachi_backends" in variables + assert variables["machine_model"] == "openstack" + assert "hitachi-backend-1" in variables["hitachi_backends"] + + backend_config = variables["hitachi_backends"]["hitachi-backend-1"] + assert "charm_config" in backend_config + + def test_hitachi_destroy_step_init( + self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest + ): + """Test HitachiDestroyStep initialization.""" + backend_instance = HitachiBackend() + backend_name = "hitachi-backend-1" + model = "openstack" + + step = HitachiDestroyStep( + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + backend_name, + backend_instance, + model, + ) + + assert step.backend_name == backend_name + assert step.backend_instance == backend_instance + assert step.model == model + + def test_hitachi_update_config_step_init(self, mock_deployment): + """Test HitachiUpdateConfigStep initialization.""" + backend_instance = HitachiBackend() + backend_name = "hitachi-backend-1" + config_updates = {"san-ip": "192.168.1.101"} + + step = HitachiUpdateConfigStep( + mock_deployment, backend_instance, backend_name, config_updates + ) + + assert step.backend_name == backend_name + assert step.backend_instance == backend_instance + assert step.config_updates == config_updates + + def test_hitachi_deploy_step_creation( + self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest + ): + """Test Hitachi deploy step creation.""" + backend_instance = HitachiBackend() + backend_name = "hitachi-backend-1" + model = "openstack" + + config = HitachiConfig( + name=backend_name, + hitachi_storage_id="123456", + hitachi_pools="pool1", + san_ip="192.168.1.100", + protocol="FC", + san_username="testuser", + san_password="testpassword", + ) + + step = HitachiDeployStep( + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + backend_name, + config, + backend_instance, + model, + ) + + # Test that step was created successfully + assert step is not None + assert step.backend_name == backend_name + assert step.backend_instance == backend_instance + + def test_hitachi_destroy_step_creation( + self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest + ): + """Test Hitachi destroy step creation.""" + backend_instance = HitachiBackend() + backend_name = "hitachi-backend-1" + model = "openstack" + + step = HitachiDestroyStep( + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + backend_name, + backend_instance, + model, + ) + + # Test that step was created successfully + assert step is not None + assert step.backend_name == backend_name + assert step.backend_instance == backend_instance + + def test_hitachi_update_config_step_creation(self, mock_deployment): + """Test Hitachi update config step creation.""" + backend_instance = HitachiBackend() + backend_name = "hitachi-backend-1" + config_updates = {"san-ip": "192.168.1.101"} + + step = HitachiUpdateConfigStep( + mock_deployment, backend_instance, backend_name, config_updates + ) + + # Test that step was created successfully + assert step is not None + assert step.backend_name == backend_name + assert step.backend_instance == backend_instance + assert step.config_updates == config_updates diff --git a/sunbeam-python/tests/unit/sunbeam/storage/test_models.py b/sunbeam-python/tests/unit/sunbeam/storage/test_models.py new file mode 100644 index 000000000..e56fea157 --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/test_models.py @@ -0,0 +1,170 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for storage backend models.""" + +import pytest +from pydantic import ValidationError + +from sunbeam.storage.models import ( + BackendAlreadyExistsException, + BackendNotFoundException, + BackendValidationException, + StorageBackendConfig, + StorageBackendException, + StorageBackendInfo, +) + + +class TestStorageBackendConfig: + """Test cases for StorageBackendConfig model.""" + + def test_valid_config(self): + """Test creating valid configuration.""" + config = StorageBackendConfig(name="test-backend") + assert config.name == "test-backend" + + def test_invalid_config_missing_name(self): + """Test validation error for missing name.""" + with pytest.raises(ValidationError): + StorageBackendConfig() + + def test_config_serialization(self): + """Test configuration serialization.""" + config = StorageBackendConfig(name="test-backend") + data = config.model_dump() + assert data == {"name": "test-backend"} + + def test_config_from_dict(self): + """Test creating configuration from dictionary.""" + data = {"name": "test-backend"} + config = StorageBackendConfig(**data) + assert config.name == "test-backend" + + +class TestStorageBackendInfo: + """Test cases for StorageBackendInfo model.""" + + def test_valid_info(self): + """Test creating valid backend info.""" + info = StorageBackendInfo( + name="test-backend", + backend_type="hitachi", + status="active", + charm="cinder-volume-hitachi", + config={"key": "value"}, + ) + assert info.name == "test-backend" + assert info.backend_type == "hitachi" + assert info.status == "active" + assert info.charm == "cinder-volume-hitachi" + assert info.config == {"key": "value"} + + def test_info_with_defaults(self): + """Test creating info with default values.""" + info = StorageBackendInfo( + name="test-backend", + backend_type="hitachi", + status="active", + charm="cinder-volume-hitachi", + ) + assert info.config == {} + + def test_info_serialization(self): + """Test backend info serialization.""" + info = StorageBackendInfo( + name="test-backend", + backend_type="hitachi", + status="active", + charm="cinder-volume-hitachi", + config={"key": "value"}, + ) + data = info.model_dump() + expected = { + "name": "test-backend", + "backend_type": "hitachi", + "status": "active", + "charm": "cinder-volume-hitachi", + "config": {"key": "value"}, + } + assert data == expected + + def test_info_from_dict(self): + """Test creating backend info from dictionary.""" + data = { + "name": "test-backend", + "backend_type": "hitachi", + "status": "active", + "charm": "cinder-volume-hitachi", + "config": {"key": "value"}, + } + info = StorageBackendInfo(**data) + assert info.name == "test-backend" + assert info.backend_type == "hitachi" + assert info.status == "active" + assert info.charm == "cinder-volume-hitachi" + assert info.config == {"key": "value"} + + +class TestStorageBackendExceptions: + """Test cases for storage backend exceptions.""" + + def test_storage_backend_exception(self): + """Test base storage backend exception.""" + exc = StorageBackendException("Test error") + assert str(exc) == "Test error" + assert isinstance(exc, Exception) + + def test_backend_not_found_exception(self): + """Test backend not found exception.""" + exc = BackendNotFoundException("Backend not found") + assert str(exc) == "Backend not found" + assert isinstance(exc, StorageBackendException) + + def test_backend_already_exists_exception(self): + """Test backend already exists exception.""" + exc = BackendAlreadyExistsException("Backend already exists") + assert str(exc) == "Backend already exists" + assert isinstance(exc, StorageBackendException) + + def test_backend_validation_exception(self): + """Test backend validation exception.""" + exc = BackendValidationException("Validation failed") + assert str(exc) == "Validation failed" + assert isinstance(exc, StorageBackendException) + + def test_exception_inheritance(self): + """Test exception inheritance hierarchy.""" + # All custom exceptions should inherit from StorageBackendException + exceptions = [ + BackendNotFoundException("test"), + BackendAlreadyExistsException("test"), + BackendValidationException("test"), + ] + + for exc in exceptions: + assert isinstance(exc, StorageBackendException) + assert isinstance(exc, Exception) + + def test_exception_with_no_message(self): + """Test exceptions with no message.""" + exc = StorageBackendException() + assert isinstance(exc, Exception) + + exc = BackendNotFoundException() + assert isinstance(exc, StorageBackendException) + + def test_exception_chaining(self): + """Test exception chaining.""" + original = ValueError("Original error") + chained = None + try: + raise original + except ValueError as e: + try: + raise StorageBackendException("Chained error") from e + except StorageBackendException as chained_exc: + chained = chained_exc + + assert str(chained) == "Chained error" + assert chained.__cause__ is original diff --git a/sunbeam-python/tests/unit/sunbeam/storage/test_registry.py b/sunbeam-python/tests/unit/sunbeam/storage/test_registry.py new file mode 100644 index 000000000..9db18185f --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/test_registry.py @@ -0,0 +1,504 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for storage backend registry.""" + +from unittest.mock import Mock, patch + +import pytest + +from sunbeam.storage.base import StorageBackendBase +from sunbeam.storage.models import StorageBackendConfig +from sunbeam.storage.registry import StorageBackendRegistry + + +class MockStorageBackend(StorageBackendBase): + """Mock storage backend for testing.""" + + name = "mock" + display_name = "Mock Storage" + description = "Mock storage backend for testing" + terraform_plan_location = "/path/to/mock/terraform" + + def __init__(self, deployment=None): + super().__init__() + self.deployment = deployment + + def _get_default_config(self) -> dict: + """Get default configuration for mock backend.""" + return {"mock_key": "default_value"} + + def prompt_for_config(self) -> dict: + """Mock prompt for config.""" + return {"mock_key": "mock_value"} + + @property + def tfvar_config_key(self): + """Config key for storing Terraform variables in clusterd.""" + return f"TerraformVars{self.name.title()}Backend" + + @property + def config_class(self): + return StorageBackendConfig + + def create_deploy_step( + self, + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + backend_config, + model, + ): + """Create a mock deployment step.""" + # Use test-specific mock step if available; otherwise base class + try: + from sunbeam.storage.steps import MockDeployStep # type: ignore + + return MockDeployStep( + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + backend_config, + self, + model, + ) + except Exception: + from sunbeam.storage.steps import BaseStorageBackendDeployStep + + return BaseStorageBackendDeployStep( + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + backend_config, + self, + model, + ) + + def create_destroy_step( + self, deployment, client, tfhelper, jhelper, manifest, backend_name, model + ): + """Create a mock destruction step.""" + # Use test-specific mock step if available; otherwise base class + try: + from sunbeam.storage.steps import MockDestroyStep # type: ignore + + return MockDestroyStep( + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + self, + model, + ) + except Exception: + from sunbeam.storage.steps import BaseStorageBackendDestroyStep + + return BaseStorageBackendDestroyStep( + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + self, + model, + ) + + def create_update_config_step(self, deployment, backend_name, config_updates): + """Create a mock configuration update step.""" + from sunbeam.storage.steps import BaseStorageBackendConfigUpdateStep + + return BaseStorageBackendConfigUpdateStep( + deployment, self, backend_name, config_updates + ) + + def prompt_for_config(self, backend_name: str) -> StorageBackendConfig: # noqa: F811 + """Mock prompt for configuration.""" + return StorageBackendConfig(name=backend_name) + + def register_add_cli(self, add): + """Mock CLI registration.""" + pass + + def register_cli( + self, remove, config_show, config_set, config_reset, config_options, deployment + ): + """Mock CLI registration.""" + pass + + def get_terraform_variables( + self, backend_name: str, config: StorageBackendConfig, model: str + ): + return { + "model": model, + "mock_backends": { + backend_name: { + "backend_type": self.name, + "charm_name": self.charm_name, + "charm_channel": "latest/stable", + "backend_config": config.model_dump(), + "backend_endpoint": "storage-backend", + "units": 1, + "additional_integrations": {}, + } + }, + } + + def commands(self): + """Return mock commands for testing.""" + return { + "add": [{"name": "mock", "command": Mock()}], + "remove": [{"name": "mock", "command": Mock()}], + "list": [{"name": "mock", "command": Mock()}], + "config": [{"name": "mock", "command": Mock()}], + } + + +class TestStorageBackendRegistry: + """Test cases for StorageBackendRegistry class.""" + + def test_init(self): + """Test registry initialization.""" + registry = StorageBackendRegistry() + + assert registry._backends == {} + assert hasattr(registry, "_loaded") + assert registry._loaded is False + + def test_load_backends_success(self): + """Test successful backend loading by directly adding a backend.""" + registry = StorageBackendRegistry() + + # Directly add a mock backend to test registry functionality + mock_backend = MockStorageBackend() + registry._backends["mock"] = mock_backend + registry._loaded = True + + assert registry._loaded is True + assert "mock" in registry._backends + assert isinstance(registry._backends["mock"], MockStorageBackend) + + @patch("sunbeam.storage.registry.pathlib.Path") + def test_load_backends_no_backends_dir(self, mock_path): + """Test loading when backends directory doesn't exist.""" + registry = StorageBackendRegistry() + + # Mock the pathlib.Path chain: + # pathlib.Path(sunbeam.storage.backends.__file__).parent + mock_backends_dir = Mock() + mock_backends_dir.iterdir.return_value = [] # Empty for no backends case + + mock_path.return_value.parent = mock_backends_dir + + registry._load_backends() + + assert registry._loaded is True + assert registry._backends == {} + + @patch("sunbeam.storage.registry.pathlib.Path") + def test_load_backends_empty_dir(self, mock_path): + """Test backend loading with empty backends directory.""" + registry = StorageBackendRegistry() + + # Mock the pathlib.Path chain: + # pathlib.Path(sunbeam.storage.backends.__file__).parent + mock_backends_dir = Mock() + mock_backends_dir.iterdir.return_value = [] + + mock_path.return_value.parent = mock_backends_dir + + registry._load_backends() + + assert registry._loaded is True + assert registry._backends == {} + + def test_get_backend_success(self): + """Test successful backend retrieval.""" + registry = StorageBackendRegistry() + mock_backend = MockStorageBackend() + registry._backends = {"mock": mock_backend} + registry._loaded = True + + backend = registry.get_backend("mock") + assert backend == mock_backend + + def test_get_backend_not_found(self): + """Test backend retrieval for non-existent backend.""" + registry = StorageBackendRegistry() + registry._backends = {} + registry._loaded = True + + with pytest.raises(ValueError, match="Storage backend 'nonexistent' not found"): + registry.get_backend("nonexistent") + + def test_get_backend_auto_load(self): + """Test that get_backend automatically loads backends if not loaded.""" + registry = StorageBackendRegistry() + + with patch.object(registry, "_load_backends") as mock_load: + mock_backend = MockStorageBackend() + registry._backends = {"mock": mock_backend} + registry._loaded = True + + backend = registry.get_backend("mock") + mock_load.assert_called_once() + assert backend == mock_backend + + def test_list_backends(self): + """Test backend listing.""" + registry = StorageBackendRegistry() + mock_backend1 = MockStorageBackend() + mock_backend2 = MockStorageBackend() + mock_backend2.name = "mock2" + + registry._backends = {"mock": mock_backend1, "mock2": mock_backend2} + registry._loaded = True + + backends = registry.list_backends() + assert len(backends) == 2 + assert "mock" in backends + assert "mock2" in backends + assert backends["mock"] == mock_backend1 + assert backends["mock2"] == mock_backend2 + + def test_list_backends_auto_load(self): + """Test that list_backends automatically loads backends if not loaded.""" + registry = StorageBackendRegistry() + + with patch.object(registry, "_load_backends") as mock_load: + registry._backends = {"mock": MockStorageBackend()} + registry._loaded = True + + backends = registry.list_backends() + mock_load.assert_called_once() + assert len(backends) == 1 + + def test_register_add_commands_via_backend(self, mock_deployment): + """Test that add commands are registered via backend.register_add_cli().""" + registry = StorageBackendRegistry() + mock_backend = MockStorageBackend() + registry._backends = {"mock": mock_backend} + registry._loaded = True + + mock_storage_group = Mock() + + with patch.object(mock_backend, "register_add_cli") as mock_register_add: + registry.register_cli_commands(mock_storage_group, mock_deployment) + + # Verify that the backend's register_add_cli method was called + mock_register_add.assert_called_once() + # Verify that groups were added to the storage group + assert mock_storage_group.add_command.call_count >= 4 + + def test_register_remove_commands_via_backend(self, mock_deployment): + """Test that remove commands are registered via backend.register_cli().""" + registry = StorageBackendRegistry() + mock_backend = MockStorageBackend() + registry._backends = {"mock": mock_backend} + registry._loaded = True + + mock_storage_group = Mock() + + with patch.object(mock_backend, "register_cli") as mock_register_cli: + registry.register_cli_commands(mock_storage_group, mock_deployment) + + # Verify that the backend's register_cli method was called + mock_register_cli.assert_called_once() + # Verify that groups were added to the storage group + assert mock_storage_group.add_command.call_count >= 4 + + def test_register_list_commands_includes_all(self, mock_deployment): + """Test that list commands include the 'all' command.""" + registry = StorageBackendRegistry() + mock_backend = MockStorageBackend() + registry._backends = {"mock": mock_backend} + registry._loaded = True + + mock_storage_group = Mock() + + with patch("sunbeam.storage.registry.StorageBackendService"): + registry.register_cli_commands(mock_storage_group, mock_deployment) + + # Verify that groups were added to the storage group + assert mock_storage_group.add_command.call_count >= 4 + + def test_register_config_commands_includes_subgroups(self, mock_deployment): + """Test that config commands include show, set, reset, options subgroups.""" + registry = StorageBackendRegistry() + mock_backend = MockStorageBackend() + registry._backends = {"mock": mock_backend} + registry._loaded = True + + mock_storage_group = Mock() + + with patch.object(mock_backend, "register_cli") as mock_register_cli: + registry.register_cli_commands(mock_storage_group, mock_deployment) + + # Verify that the backend's register_cli method was called with config + # subgroups + mock_register_cli.assert_called_once() + # The call should include the config subgroups as arguments + call_args = mock_register_cli.call_args[0] + assert ( + len(call_args) >= 5 + ) # remove_group, config_show, config_set, config_reset, config_options + + def test_register_commands_all_groups(self, mock_deployment): + """Test registration of all command groups.""" + registry = StorageBackendRegistry() + mock_backend = MockStorageBackend() + registry._backends = {"mock": mock_backend} + registry._loaded = True + + mock_cli = Mock() + + with ( + patch.object(mock_backend, "register_add_cli") as mock_add, + patch.object(mock_backend, "register_cli") as mock_register_cli, + ): + registry.register_cli_commands(mock_cli, mock_deployment) + + # Verify that backend CLI registration methods were called + mock_add.assert_called_once() + mock_register_cli.assert_called_once() + + # Verify that the CLI groups were added to the storage group + assert ( + mock_cli.add_command.call_count >= 4 + ) # add, remove, list, config groups + + def test_backend_discovery_error_handling(self): + """Test error handling during backend discovery.""" + registry = StorageBackendRegistry() + + with ( + patch("sunbeam.storage.registry.importlib.import_module"), + patch("sunbeam.storage.registry.pathlib.Path") as mock_path, + ): + mock_backends_dir = Mock() + mock_backends_dir.iterdir.side_effect = Exception("Directory read error") + + mock_path.return_value.parent = mock_backends_dir + + # Registry should handle directory iteration gracefully + registry._load_backends() + + # Should still mark as loaded even with directory errors + assert registry._loaded is True + assert registry._backends == {} + + def test_module_loading_error_handling(self): + """Test error handling during module loading.""" + registry = StorageBackendRegistry() + + with ( + patch("sunbeam.storage.registry.pathlib.Path") as mock_path, + patch( + "sunbeam.storage.registry.importlib.import_module" + ) as mock_import_module, + ): + mock_backends_dir = Mock() + mock_backends_dir.exists.return_value = True + mock_backends_dir.is_dir.return_value = True + + mock_backend_dir = Mock() + mock_backend_dir.name = "mock" + mock_backend_dir.is_dir.return_value = True + + # Set up path operations for mock_backend_dir + mock_backend_module_path = Mock() + mock_backend_module_path.exists.return_value = True + mock_backend_dir.__truediv__ = Mock(return_value=mock_backend_module_path) + + mock_backends_dir.iterdir.return_value = [mock_backend_dir] + + mock_path.return_value.parent = mock_backends_dir + + mock_import_module.side_effect = Exception("Module loading error") + + # Should not raise exception, just log error and continue + registry._load_backends() + + assert registry._loaded is True + assert registry._backends == {} + + def test_singleton_behavior(self): + """Test that registry instances are independent (not singleton).""" + registry1 = StorageBackendRegistry() + registry2 = StorageBackendRegistry() + + # Should be different instances + assert registry1 is not registry2 + assert registry1._backends is not registry2._backends + + def test_backend_validation(self): + """Test backend validation during loading.""" + registry = StorageBackendRegistry() + + # Mock a class that doesn't inherit from StorageBackendBase + class InvalidBackend: + name = "invalid" + + # Directly test validation by adding backends + # Valid backend should be accepted + valid_backend = MockStorageBackend() + registry._backends["mock"] = valid_backend + + # Test that we can access the valid backend + assert len(registry._backends) == 1 + assert "mock" in registry._backends + assert isinstance(registry._backends["mock"], MockStorageBackend) + + def test_command_registration_with_no_backends(self, mock_deployment): + """Test command registration when no backends are loaded.""" + registry = StorageBackendRegistry() + registry._backends = {} + registry._loaded = True + + mock_cli = Mock() + + # Should not raise errors even with no backends + with patch("sunbeam.storage.registry.StorageBackendService"): + registry.register_cli_commands(mock_cli, mock_deployment) + + # Should still create the basic command groups + assert mock_cli.add_command.call_count >= 4 + + def test_command_registration_with_backend_errors(self, mock_deployment): + """Test command registration when backend registration fails.""" + registry = StorageBackendRegistry() + mock_backend = MockStorageBackend() + registry._backends = {"mock": mock_backend} + registry._loaded = True + + mock_cli = Mock() + + with ( + patch.object( + mock_backend, + "register_add_cli", + side_effect=Exception("Registration error"), + ), + patch.object( + mock_backend, + "register_cli", + side_effect=Exception("Registration error"), + ), + patch("sunbeam.storage.registry.StorageBackendService"), + ): + # Should not raise errors even if backend registration fails + registry.register_cli_commands(mock_cli, mock_deployment) + + # Should still create the basic command groups + assert mock_cli.add_command.call_count >= 4 diff --git a/sunbeam-python/tests/unit/sunbeam/storage/test_service.py b/sunbeam-python/tests/unit/sunbeam/storage/test_service.py new file mode 100644 index 000000000..73c52a884 --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/test_service.py @@ -0,0 +1,206 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for storage backend service layer.""" + +import pytest + +from sunbeam.clusterd.service import ConfigItemNotFoundException +from sunbeam.storage.models import ( + BackendNotFoundException, +) +from sunbeam.storage.service import StorageBackendService + + +class TestStorageBackendService: + """Test cases for StorageBackendService class.""" + + def test_init(self, mock_deployment): + """Test service initialization.""" + service = StorageBackendService(mock_deployment) + + assert service.deployment == mock_deployment + assert service.model == "admin/openstack" + assert service._tfvar_config_key == "TerraformVarsStorageBackends" + + def test_get_model_name_with_admin_prefix(self, mock_deployment): + """Test model name retrieval when model already has admin prefix.""" + mock_deployment.openstack_machines_model = "admin/openstack" + service = StorageBackendService(mock_deployment) + + assert service.model == "admin/openstack" + + def test_get_model_name_without_admin_prefix(self, mock_deployment): + """Test model name retrieval when missing admin prefix.""" + mock_deployment.openstack_machines_model = "openstack" + service = StorageBackendService(mock_deployment) + + assert service.model == "admin/openstack" + + def test_list_backends_success(self, mock_deployment, sample_clusterd_config): + """Test successful backend listing.""" + import json + from unittest.mock import Mock, patch + + mock_client = mock_deployment.get_client.return_value + # read_config expects JSON string, not dict + mock_client.cluster.get_config.return_value = json.dumps( + sample_clusterd_config["TerraformVarsStorageBackends"] + ) + + # Mock JujuHelper and model status for dynamic status resolution + with patch("sunbeam.storage.service.JujuHelper") as mock_juju_helper: + # Create mock status objects + mock_app_status = Mock() + mock_app_status.app_status.current = "active" + mock_app_status.charm = "ch:amd64/jammy/cinder-volume-hitachi-123" + + mock_model_status = Mock() + # App name is set to backend_name directly in Terraform + mock_model_status.apps = {"test-backend": mock_app_status} + + mock_juju_instance = Mock() + mock_juju_instance.get_model_status.return_value = mock_model_status + mock_juju_helper.return_value = mock_juju_instance + + service = StorageBackendService(mock_deployment) + backends = service.list_backends() + + assert len(backends) == 1 + assert backends[0].name == "test-backend" + assert backends[0].backend_type == "hitachi" + assert backends[0].status == "active" + assert backends[0].charm == "ch:amd64/jammy/cinder-volume-hitachi-123" + + def test_list_backends_no_config(self, mock_deployment): + """Test backend listing when no configuration exists.""" + mock_client = mock_deployment.get_client.return_value + mock_client.cluster.get_config.side_effect = ConfigItemNotFoundException("test") + + service = StorageBackendService(mock_deployment) + backends = service.list_backends() + + assert backends == [] + + def test_list_backends_empty_config(self, mock_deployment): + """Test backend listing with empty configuration.""" + mock_client = mock_deployment.get_client.return_value + mock_client.cluster.get_config.return_value = { + "TerraformVarsStorageBackends": {} + } + + service = StorageBackendService(mock_deployment) + backends = service.list_backends() + + assert backends == [] + + def test_list_backends_no_backends_key(self, mock_deployment): + """Test backend listing when backends key is missing.""" + mock_client = mock_deployment.get_client.return_value + mock_client.cluster.get_config.return_value = { + "TerraformVarsStorageBackends": {"model": "openstack"} + } + + service = StorageBackendService(mock_deployment) + backends = service.list_backends() + + assert backends == [] + + def test_backend_exists_true(self, mock_deployment, sample_clusterd_config): + """Test backend existence check when backend exists.""" + import json + + mock_client = mock_deployment.get_client.return_value + mock_client.cluster.get_config.return_value = json.dumps( + sample_clusterd_config["TerraformVarsStorageBackends"] + ) + + service = StorageBackendService(mock_deployment) + exists = service.backend_exists("test-backend", "hitachi") + + assert exists is True + + def test_backend_exists_false(self, mock_deployment, sample_clusterd_config): + """Test backend existence check when backend doesn't exist.""" + import json + + mock_client = mock_deployment.get_client.return_value + mock_client.cluster.get_config.return_value = json.dumps( + sample_clusterd_config["TerraformVarsStorageBackends"] + ) + + service = StorageBackendService(mock_deployment) + result = service.backend_exists("nonexistent-backend", "hitachi") + + assert result is False + + def test_backend_exists_no_config(self, mock_deployment): + """Test backend existence check when no configuration exists.""" + from sunbeam.clusterd.service import ConfigItemNotFoundException + + mock_client = mock_deployment.get_client.return_value + mock_client.cluster.get_config.side_effect = ConfigItemNotFoundException( + "Config not found" + ) + + service = StorageBackendService(mock_deployment) + result = service.backend_exists("test-backend", "hitachi") + + assert result is False + + def test_get_backend_config_success(self, mock_deployment, sample_clusterd_config): + """Test successful backend config retrieval.""" + import json + + mock_client = mock_deployment.get_client.return_value + mock_client.cluster.get_config.return_value = json.dumps( + sample_clusterd_config["TerraformVarsStorageBackends"] + ) + + service = StorageBackendService(mock_deployment) + config = service.get_backend_config("test-backend", "hitachi") + + assert isinstance(config, dict) + assert "hitachi-storage-id" in config + assert config["hitachi-storage-id"] == "123456" + + def test_get_backend_config_not_found( + self, mock_deployment, sample_clusterd_config + ): + """Test backend config retrieval for non-existent backend.""" + import json + + mock_client = mock_deployment.get_client.return_value + mock_client.cluster.get_config.return_value = json.dumps( + sample_clusterd_config["TerraformVarsStorageBackends"] + ) + + service = StorageBackendService(mock_deployment) + + with pytest.raises(BackendNotFoundException): + service.get_backend_config("nonexistent-backend", "hitachi") + + def test_get_backend_config_no_config(self, mock_deployment): + """Test backend config retrieval when no configuration exists.""" + from sunbeam.clusterd.service import ConfigItemNotFoundException + + mock_client = mock_deployment.get_client.return_value + mock_client.cluster.get_config.side_effect = ConfigItemNotFoundException( + "Config not found" + ) + + service = StorageBackendService(mock_deployment) + + with pytest.raises(BackendNotFoundException): + service.get_backend_config("test-backend", "hitachi") + + def test_error_handling_client_exception(self, mock_deployment): + """Test error handling when client raises exception.""" + mock_client = mock_deployment.get_client.return_value + mock_client.cluster.get_config.side_effect = Exception("Client error") + + service = StorageBackendService(mock_deployment) + + # The service logs the error but returns empty list instead of raising exception + backends = service.list_backends() + assert backends == [] diff --git a/sunbeam-python/tests/unit/sunbeam/storage/test_storage_base.py b/sunbeam-python/tests/unit/sunbeam/storage/test_storage_base.py new file mode 100644 index 000000000..e4e3edde6 --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/test_storage_base.py @@ -0,0 +1,296 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for storage backend base classes.""" + +from pathlib import Path +from unittest.mock import Mock, patch + +from sunbeam.storage.base import StorageBackendBase +from sunbeam.storage.models import StorageBackendConfig +from sunbeam.storage.service import StorageBackendService + + +class MockStorageBackend(StorageBackendBase): + """Mock storage backend for testing.""" + + name = "mock" + display_name = "Mock Storage Backend" + charm_name = "mock-charm" + + def __init__(self): + super().__init__() + + @property + def tfvar_config_key(self): + """Config key for storing Terraform variables in clusterd.""" + return f"TerraformVars{self.name.title()}Backend" + + @property + def config_class(self): + return StorageBackendConfig + + def get_terraform_variables( + self, backend_name: str, config: StorageBackendConfig, model: str + ): + return { + "model": model, + "mock_backends": { + backend_name: { + "backend_type": self.name, + "charm_name": self.charm_name, + "charm_channel": self.charm_channel, + "backend_config": config.model_dump(), + "backend_endpoint": self.backend_endpoint, + "units": self.units, + "additional_integrations": self.additional_integrations, + } + }, + } + + def commands(self, conditions=None): + """Return command mapping for this backend.""" + return {} + + def prompt_for_config(self, backend_name: str): + """Mock implementation of prompt_for_config.""" + return StorageBackendConfig(name=backend_name) + + def create_deploy_step( + self, + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + config, + model, + ): + """Create a deployment step for this backend.""" + from sunbeam.storage.steps import BaseStorageBackendDeployStep + + class MockDeployStep(BaseStorageBackendDeployStep): + def get_terraform_variables(self): + return self.backend_instance.get_terraform_variables( + self.backend_name, self.backend_config, self.model + ) + + return MockDeployStep( + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + config, + self, + model, + ) + + def create_destroy_step( + self, deployment, client, tfhelper, jhelper, manifest, backend_name, model + ): + """Create a destruction step for this backend.""" + from sunbeam.storage.steps import BaseStorageBackendDestroyStep + + class MockDestroyStep(BaseStorageBackendDestroyStep): + def get_terraform_variables(self): + return self.backend_instance.get_terraform_variables( + self.backend_name, + StorageBackendConfig(name=self.backend_name), + self.model, + ) + + return MockDestroyStep( + deployment, client, tfhelper, jhelper, manifest, backend_name, self, model + ) + + def create_update_config_step(self, deployment, backend_name, config_updates): + """Create a configuration update step for this backend.""" + from sunbeam.storage.steps import BaseStorageBackendConfigUpdateStep + + return BaseStorageBackendConfigUpdateStep( + deployment, self, backend_name, config_updates + ) + + def register_add_cli(self, add): + """Mock CLI registration.""" + pass + + def register_cli( + self, remove, config_show, config_set, config_reset, config_options, deployment + ): + """Mock CLI registration.""" + pass + + def _get_default_config(self) -> StorageBackendConfig: + """Get a default configuration instance for comparison.""" + return StorageBackendConfig(name="default") + + +class TestStorageBackendBase: + """Test cases for StorageBackendBase class.""" + + def test_init(self): + """Test backend initialization.""" + backend = MockStorageBackend() + + assert backend.name == "mock" + assert backend.display_name == "Mock Storage Backend" + assert backend.charm_name == "mock-charm" + assert backend.tfplan == "storage-backend-plan" + assert backend.tfplan_dir == "deploy-storage-backend" + assert backend.charm_channel == "stable" + assert backend.charm_base == "ubuntu@22.04" + assert backend.backend_endpoint == "cinder-volume" + assert backend.units == 1 + assert backend.additional_integrations == [] + + def test_config_class(self): + """Test configuration class retrieval.""" + backend = MockStorageBackend() + config_class = backend.config_class + assert config_class == StorageBackendConfig + + def test_get_terraform_variables(self): + """Test Terraform variables generation.""" + backend = MockStorageBackend() + config = StorageBackendConfig(name="test-backend") + + variables = backend.get_terraform_variables("test-backend", config, "openstack") + + assert "model" in variables + assert "mock_backends" in variables + assert variables["model"] == "openstack" + assert "test-backend" in variables["mock_backends"] + + backend_config = variables["mock_backends"]["test-backend"] + assert backend_config["backend_type"] == "mock" + assert backend_config["charm_name"] == "mock-charm" + assert backend_config["charm_channel"] == "stable" + assert backend_config["backend_endpoint"] == "cinder-volume" + assert backend_config["units"] == 1 + + # todo: fix this implementation. the get service is not part of the base class + @patch("sunbeam.storage.base.StorageBackendService") + def test_get_service(self, mock_service_class, mock_deployment): + """Test service creation and caching.""" + backend = MockStorageBackend() + mock_service = Mock(spec=StorageBackendService) + mock_service_class.return_value = mock_service + + # First call should create service + service1 = backend._get_service(mock_deployment) + assert service1 == mock_service + mock_service_class.assert_called_once_with(mock_deployment) + + # Second call should return cached service + service2 = backend._get_service(mock_deployment) + assert service2 == mock_service + # Should not call constructor again + assert mock_service_class.call_count == 1 + + def test_prompt_for_config(self, mock_deployment): + """Test configuration prompting (base implementation).""" + backend = MockStorageBackend() + + # Base implementation should return empty config + config = backend.prompt_for_config("test-backend") + assert isinstance(config, StorageBackendConfig) + assert config.name == "test-backend" + + def test_abstract_methods_not_implemented(self): + """Test that abstract methods raise NotImplementedError in base class.""" + # This test verifies that StorageBackendBase is properly abstract + # We can't instantiate it directly, but we can test that our mock + # implements required methods + backend = MockStorageBackend() + + # These methods should be implemented in the mock + assert hasattr(backend, "config_class") + assert hasattr(backend, "get_terraform_variables") + assert callable(backend.config_class) + assert callable(backend.get_terraform_variables) + + def test_version_property(self): + """Test version property.""" + backend = MockStorageBackend() + assert hasattr(backend, "version") + # Version should be set in base class + assert str(backend.version) == "0.0.1" + + def test_tf_plan_location_property(self): + """Test Terraform plan location property.""" + backend = MockStorageBackend() + assert backend.tf_plan_location == "FEATURE_REPO" + + def test_user_manifest_property(self): + """Test user manifest property.""" + backend = MockStorageBackend() + assert backend.user_manifest is None + + @patch("sunbeam.storage.base.Path") + def test_tfplan_path_property(self, mock_path): + """Test Terraform plan path property.""" + backend = MockStorageBackend() + mock_path.return_value = Path("/test/path") + + # This would test the tfplan_path property if it exists + # The actual implementation may vary + assert hasattr(backend, "tfplan_dir") + assert backend.tfplan_dir == "deploy-storage-backend" + + def test_backend_attributes(self): + """Test that all required backend attributes are set.""" + backend = MockStorageBackend() + + # Test required string attributes + required_attrs = ["name", "display_name", "charm_name", "tfplan", "tfplan_dir"] + for attr in required_attrs: + assert hasattr(backend, attr) + assert isinstance(getattr(backend, attr), str) + assert getattr(backend, attr) != "" + + # Test optional attributes + optional_attrs = [ + "charm_channel", + "charm_revision", + "charm_base", + "backend_endpoint", + ] + for attr in optional_attrs: + assert hasattr(backend, attr) + + # Test integer attributes + assert hasattr(backend, "units") + assert isinstance(backend.units, int) + assert backend.units > 0 + + # Test list attributes + assert hasattr(backend, "additional_integrations") + assert isinstance(backend.additional_integrations, list) + + def test_service_caching_with_different_deployments(self, mock_deployment): + """Test service caching behavior with different deployment objects.""" + backend = MockStorageBackend() + + with patch("sunbeam.storage.base.StorageBackendService") as mock_service_class: + mock_service1 = Mock(spec=StorageBackendService) + mock_service_class.return_value = mock_service1 + + # First deployment + service1 = backend._get_service(mock_deployment) + assert service1 == mock_service1 + + # Different deployment object should return same cached service + mock_deployment2 = Mock() + service2 = backend._get_service(mock_deployment2) + assert service2 == mock_service1 # Same service is cached and reused + + # Same deployment should return cached service + service1_cached = backend._get_service(mock_deployment) + assert service1_cached == mock_service1 + + # Should have called constructor only once (service is cached) + assert mock_service_class.call_count == 1 diff --git a/sunbeam-python/tests/unit/sunbeam/storage/test_storage_steps.py b/sunbeam-python/tests/unit/sunbeam/storage/test_storage_steps.py new file mode 100644 index 000000000..a171cc6d2 --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/test_storage_steps.py @@ -0,0 +1,594 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for Terraform-based storage backend step classes.""" + +from unittest.mock import Mock, patch + +from sunbeam.core.common import ResultType +from sunbeam.storage.base import StorageBackendBase +from sunbeam.storage.models import StorageBackendConfig +from sunbeam.storage.steps import ( + BaseStorageBackendConfigUpdateStep, + BaseStorageBackendDeployStep, + BaseStorageBackendDestroyStep, +) + + +class MockStorageBackend(StorageBackendBase): + """Mock storage backend for testing.""" + + name = "mock" + display_name = "Mock Storage" + description = "Mock storage backend for testing" + terraform_plan_location = "/path/to/mock/terraform" + charm_name = "mock-charm" + + def __init__(self, deployment=None): + super().__init__() + self.deployment = deployment + + def _get_default_config(self) -> dict: + """Get default configuration for mock backend.""" + return {"mock_key": "default_value"} + + def prompt_for_config(self) -> dict: + """Mock prompt for config.""" + return {"mock_key": "mock_value"} + + @property + def tfvar_config_key(self): + """Config key for storing Terraform variables in clusterd.""" + return f"TerraformVars{self.name.title()}Backend" + + def register_add_cli(self, add): + """Mock CLI registration.""" + pass + + def register_cli( + self, remove, config_show, config_set, config_reset, config_options, deployment + ): + """Mock CLI registration.""" + pass + + def get_terraform_variables(self, backend_name, config, model): + """Mock Terraform variables.""" + return { + "model": model, + "mock_backends": { + backend_name: { + "backend_type": self.name, + "charm_name": self.charm_name, + "charm_channel": "latest/stable", + "backend_config": config.model_dump(), + "backend_endpoint": "storage-backend", + "units": 1, + "additional_integrations": {}, + } + }, + } + + def create_deploy_step( + self, + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + backend_config, + model, + ): + """Create a mock deployment step.""" + return MockDeployStep( + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + backend_config, + self, + model, + ) + + def create_destroy_step( + self, deployment, client, tfhelper, jhelper, manifest, backend_name, model + ): + """Create a mock destruction step.""" + return MockDestroyStep( + deployment, client, tfhelper, jhelper, manifest, backend_name, self, model + ) + + def create_update_config_step(self, deployment, backend_name, config_updates): + """Create a mock configuration update step.""" + return BaseStorageBackendConfigUpdateStep( + deployment, self, backend_name, config_updates + ) + + def prompt_for_config(self, backend_name: str) -> StorageBackendConfig: # noqa: F811 + """Mock prompt for configuration.""" + return StorageBackendConfig(name=backend_name) + + def get_field_mapping(self): + """Return field mapping for mock backend.""" + return {} + + def commands(self): + """Return commands for mock backend.""" + return [] + + +class MockDeployStep(BaseStorageBackendDeployStep): + """Concrete implementation of BaseStorageBackendDeployStep for testing.""" + + def get_terraform_variables(self) -> dict: + """Get terraform variables for deployment.""" + return self.backend_instance.get_terraform_variables( + self.backend_name, self.backend_config, self.model + ) + + +class MockDestroyStep(BaseStorageBackendDestroyStep): + """Concrete implementation of BaseStorageBackendDestroyStep for testing.""" + + pass + + +class TestBaseStorageBackendDeployStep: + """Test cases for BaseStorageBackendDeployStep.""" + + def test_init( + self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest + ): + """Test step initialization.""" + backend_instance = MockStorageBackend() + backend_name = "test-backend" + backend_config = StorageBackendConfig(name=backend_name) + model = "openstack" + + step = MockDeployStep( + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + backend_name, + backend_config, + backend_instance, + model, + ) + + assert step.deployment == mock_deployment + assert step.client == mock_client + assert step.tfhelper == mock_tfhelper + assert step.jhelper == mock_jhelper + assert step.manifest == mock_manifest + assert step.backend_name == backend_name + assert step.backend_config == backend_config + assert step.backend_instance == backend_instance + assert step.model == model + assert "Deploy Mock Storage backend" in step.name + assert "test-backend" in step.description + + @patch("sunbeam.storage.steps.read_config") + @patch("sunbeam.storage.steps.update_config") + def test_run_success( + self, + mock_update_config, + mock_read_config, + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + ): + """Test successful deployment run.""" + # Mock existing config + mock_read_config.return_value = {"existing": "config"} + + # Mock Terraform operations + mock_tfhelper.update_tfvars_and_apply_tf.return_value = None + + backend_instance = MockStorageBackend() + backend_name = "test-backend" + backend_config = StorageBackendConfig(name=backend_name) + model = "openstack" + + step = MockDeployStep( + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + backend_name, + backend_config, + backend_instance, + model, + ) + + result = step.run() + + assert result.result_type == ResultType.COMPLETED + + # Verify Terraform was called with correct variables + mock_tfhelper.update_tfvars_and_apply_tf.assert_called_once() + call_args = mock_tfhelper.update_tfvars_and_apply_tf.call_args + tfvars = call_args[1]["override_tfvars"] + + assert "model" in tfvars + assert "mock_backends" in tfvars + assert tfvars["model"] == "openstack" + + def test_get_application_timeout( + self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest + ): + """Test application timeout retrieval.""" + backend_instance = MockStorageBackend() + backend_name = "test-backend" + backend_config = StorageBackendConfig(name=backend_name) + model = "openstack" + + step = MockDeployStep( + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + backend_name, + backend_config, + backend_instance, + model, + ) + + timeout = step.get_application_timeout() + assert timeout == 1200 # Default timeout + + +class TestBaseStorageBackendDestroyStep: + """Test cases for BaseStorageBackendDestroyStep.""" + + def test_init( + self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest + ): + """Test step initialization.""" + backend_instance = MockStorageBackend() + backend_name = "test-backend" + model = "openstack" + + step = BaseStorageBackendDestroyStep( + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + backend_name, + backend_instance, + model, + ) + + assert step.deployment == mock_deployment + assert step.client == mock_client + assert step.tfhelper == mock_tfhelper + assert step.jhelper == mock_jhelper + assert step.manifest == mock_manifest + assert step.backend_name == backend_name + assert step.backend_instance == backend_instance + assert step.model == model + assert "Destroy Mock Storage backend" in step.name + assert "test-backend" in step.description + + @patch("sunbeam.storage.steps.read_config") + @patch("sunbeam.storage.steps.update_config") + def test_run_success_partial_destroy( + self, + mock_update_config, + mock_read_config, + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + ): + """Test successful destruction run with partial destroy.""" + # Mock existing config with multiple backends + mock_read_config.return_value = { + "mock_backends": { + "test-backend": {"backend_type": "mock"}, + "other-backend": {"backend_type": "hitachi"}, + } + } + + # Mock Terraform operations + mock_tfhelper.apply.return_value = None + + backend_instance = MockStorageBackend() + backend_name = "test-backend" + model = "openstack" + + step = BaseStorageBackendDestroyStep( + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + backend_name, + backend_instance, + model, + ) + + # Mock should_destroy_all_resources to return False + with patch.object(step, "should_destroy_all_resources", return_value=False): + result = step.run() + + assert result.result_type == ResultType.COMPLETED + + # Verify Terraform was called + mock_tfhelper.apply.assert_called_once() + + @patch("sunbeam.storage.steps.read_config") + def test_run_success_full_destroy( + self, + mock_read_config, + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + ): + """Test successful destruction run with full destroy.""" + # Mock existing config with only one backend + mock_read_config.return_value = { + "mock_backends": {"test-backend": {"backend_type": "mock"}} + } + + # Mock Terraform operations + mock_tfhelper.destroy.return_value = None + + backend_instance = MockStorageBackend() + backend_name = "test-backend" + model = "openstack" + + step = BaseStorageBackendDestroyStep( + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + backend_name, + backend_instance, + model, + ) + + # Mock should_destroy_all_resources to return True + with patch.object(step, "should_destroy_all_resources", return_value=True): + result = step.run() + + assert result.result_type == ResultType.COMPLETED + + # Verify Terraform destroy was called + mock_tfhelper.destroy.assert_called_once() + + @patch("sunbeam.storage.steps.read_config") + def test_should_destroy_all_resources_true( + self, + mock_read_config, + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + ): + """Test should_destroy_all_resources logic when only one backend exists.""" + # Mock config with only one backend + mock_read_config.return_value = { + "mock_backends": {"test-backend": {"backend_type": "mock"}} + } + + backend_instance = MockStorageBackend() + backend_name = "test-backend" + model = "openstack" + + step = BaseStorageBackendDestroyStep( + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + backend_name, + backend_instance, + model, + ) + + result = step.should_destroy_all_resources() + assert result is True + + @patch("sunbeam.storage.steps.read_config") + def test_should_destroy_all_resources_false( + self, + mock_read_config, + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + ): + """Test should_destroy_all_resources logic when multiple backends exist.""" + # Mock config with multiple backends + mock_read_config.return_value = { + "mock_backends": { + "test-backend": {"backend_type": "mock"}, + "other-backend": {"backend_type": "hitachi"}, + } + } + + backend_instance = MockStorageBackend() + backend_name = "test-backend" + model = "openstack" + + step = BaseStorageBackendDestroyStep( + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + backend_name, + backend_instance, + model, + ) + + result = step.should_destroy_all_resources() + assert result is False + + def test_get_application_timeout( + self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest + ): + """Test application timeout retrieval.""" + backend_instance = MockStorageBackend() + backend_name = "test-backend" + model = "openstack" + + step = BaseStorageBackendDestroyStep( + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + backend_name, + backend_instance, + model, + ) + + timeout = step.get_application_timeout() + assert timeout == 1200 # Default timeout + + +class TestBaseStorageBackendConfigUpdateStep: + """Test cases for BaseStorageBackendConfigUpdateStep.""" + + def test_init(self, mock_deployment): + """Test step initialization.""" + backend_instance = MockStorageBackend() + backend_name = "test-backend" + config_updates = {"key1": "value1", "key2": "value2"} + + # Mock deployment methods + mock_client = Mock() + mock_tfhelper = Mock() + mock_deployment.get_client.return_value = mock_client + mock_deployment.get_tfhelper.return_value = mock_tfhelper + + step = BaseStorageBackendConfigUpdateStep( + mock_deployment, backend_instance, backend_name, config_updates + ) + + assert step.deployment == mock_deployment + assert step.backend_instance == backend_instance + assert step.backend_name == backend_name + assert step.config_updates == config_updates + assert step.client == mock_client + assert step.tfhelper == mock_tfhelper + assert "Update Mock Storage backend" in step.name + assert "test-backend" in step.description + + @patch("sunbeam.storage.steps.read_config") + @patch("sunbeam.storage.steps.update_config") + def test_run_update_operation( + self, mock_update_config, mock_read_config, mock_deployment + ): + """Test configuration update operation.""" + # Mock existing config + mock_read_config.return_value = { + "mock_backends": { + "test-backend": {"backend_config": {"existing_key": "existing_value"}} + } + } + + backend_instance = MockStorageBackend() + backend_name = "test-backend" + config_updates = {"key1": "value1", "key2": "value2"} + + # Mock deployment methods + mock_client = Mock() + mock_tfhelper = Mock() + mock_tfhelper.write_tfvars.return_value = None + mock_tfhelper.apply.return_value = None + mock_deployment.get_client.return_value = mock_client + mock_deployment.get_tfhelper.return_value = mock_tfhelper + + step = BaseStorageBackendConfigUpdateStep( + mock_deployment, backend_instance, backend_name, config_updates + ) + + result = step.run() + + assert result.result_type == ResultType.COMPLETED + + # Verify Terraform was called + mock_tfhelper.apply.assert_called_once() + + @patch("sunbeam.storage.steps.read_config") + @patch("sunbeam.storage.steps.update_config") + def test_run_reset_operation( + self, mock_update_config, mock_read_config, mock_deployment + ): + """Test configuration reset operation.""" + # Mock existing config + mock_read_config.return_value = { + "mock_backends": { + "test-backend": {"backend_config": {"key1": "value1", "key2": "value2"}} + } + } + + backend_instance = MockStorageBackend() + backend_name = "test-backend" + config_updates = {"_reset_keys": ["key1"]} + + # Mock deployment methods + mock_client = Mock() + mock_tfhelper = Mock() + mock_tfhelper.write_tfvars.return_value = None + mock_tfhelper.apply.return_value = None + mock_deployment.get_client.return_value = mock_client + mock_deployment.get_tfhelper.return_value = mock_tfhelper + + step = BaseStorageBackendConfigUpdateStep( + mock_deployment, backend_instance, backend_name, config_updates + ) + + result = step.run() + + assert result.result_type == ResultType.COMPLETED + + # Verify Terraform was called + mock_tfhelper.apply.assert_called_once() + + def test_handle_update_operation(self, mock_deployment): + """Test update operation handling.""" + backend_instance = MockStorageBackend() + backend_name = "test-backend" + config_updates = {"key1": "value1", "key2": "value2"} + + step = BaseStorageBackendConfigUpdateStep( + mock_deployment, backend_instance, backend_name, config_updates + ) + + current_config = { + "mock_backends": { + "test-backend": {"charm_config": {"existing_key": "existing_value"}} + } + } + updated_config = step.handle_update_operation(current_config) + + expected_config = { + "mock_backends": { + "test-backend": { + "charm_config": { + "existing_key": "existing_value", + "key1": "value1", + "key2": "value2", + } + } + } + } + assert updated_config == expected_config From e297f5d9ad5efd8145cf5166f264eec1b0a08481 Mon Sep 17 00:00:00 2001 From: Guillaume Boutry Date: Wed, 22 Oct 2025 14:04:24 +0200 Subject: [PATCH 2/4] refactor(storage): Simplify backend management Based on the assumptions that only a single subordinate charm is required, simplify the backends to only contain: a pydantic config, holding no default values (except none), and a backend instance to host the backend specific values (charm, channel...). Implement the a storage-backends api/database in clusterd to have a single managed places where we store these information. Remove backend_type from all commands except add, we don't support having multiple backends named the same way across providers. Signed-off-by: Guillaume Boutry --- cloud/etc/deploy-storage/main.tf | 35 + .../deploy-storage/modules/backend/main.tf | 74 ++ .../modules/backend/variables.tf | 59 ++ cloud/etc/deploy-storage/outputs.tf | 2 + cloud/etc/deploy-storage/variables.tf | 22 + .../api/apitypes/storage_backends.go | 19 + sunbeam-microcluster/api/servers.go | 2 + sunbeam-microcluster/api/storage_backends.go | 117 +++ sunbeam-microcluster/database/schema.go | 19 + .../database/storage_backend.go | 40 + .../database/storage_backend.mapper.go | 422 +++++++++ .../sunbeam/storage_backend.go | 127 +++ sunbeam-python/sunbeam/clusterd/cluster.py | 54 +- sunbeam-python/sunbeam/clusterd/models.py | 30 + sunbeam-python/sunbeam/clusterd/service.py | 10 + sunbeam-python/sunbeam/commands/storage.py | 46 - sunbeam-python/sunbeam/core/common.py | 7 +- sunbeam-python/sunbeam/core/deployment.py | 47 + sunbeam-python/sunbeam/core/manifest.py | 62 +- sunbeam-python/sunbeam/errors.py | 6 + sunbeam-python/sunbeam/main.py | 4 +- .../storage/backends/dellsc/__init__.py | 5 - .../storage/backends/dellsc/backend.py | 460 +++------- .../sunbeam/storage/backends/dellsc/cli.py | 344 -------- .../dellsc/deploy-dellsc-backend/main.tf | 140 --- .../dellsc/deploy-dellsc-backend/outputs.tf | 13 - .../test-variables.tfvars | 41 - .../dellsc/deploy-dellsc-backend/variables.tf | 65 -- .../storage/backends/hitachi/__init__.py | 4 + .../storage/backends/hitachi/backend.py | 812 ++++++------------ .../sunbeam/storage/backends/hitachi/cli.py | 344 -------- .../hitachi/deploy-hitachi-backend/main.tf | 222 ----- .../hitachi/deploy-hitachi-backend/outputs.tf | 13 - .../test-variables.tfvars | 35 - .../deploy-hitachi-backend/variables.tf | 74 -- .../storage/backends/purestorage/__init__.py | 4 + .../storage/backends/purestorage/backend.py | 489 +++-------- .../storage/backends/purestorage/cli.py | 346 -------- .../deploy-purestorage-backend/main.tf | 56 -- .../deploy-purestorage-backend/outputs.tf | 13 - .../deploy-purestorage-backend/variables.tf | 58 -- sunbeam-python/sunbeam/storage/base.py | 532 ++++++------ sunbeam-python/sunbeam/storage/cli_base.py | 259 ++++++ sunbeam-python/sunbeam/storage/manager.py | 298 +++++++ sunbeam-python/sunbeam/storage/models.py | 27 +- sunbeam-python/sunbeam/storage/registry.py | 192 ----- sunbeam-python/sunbeam/storage/service.py | 197 ++--- sunbeam-python/sunbeam/storage/steps.py | 603 ++++++------- sunbeam-python/sunbeam/utils.py | 8 +- .../{backends/hitachi => }/__init__.py | 2 - .../unit/sunbeam/storage/backends/__init__.py | 4 - .../unit/sunbeam/storage/backends/conftest.py | 39 + .../storage/backends/hitachi/test_cli.py | 367 -------- .../sunbeam/storage/backends/test_common.py | 224 +++++ .../sunbeam/storage/backends/test_dellsc.py | 322 +++++++ .../sunbeam/storage/backends/test_hitachi.py | 244 ++++++ .../storage/backends/test_purestorage.py | 269 ++++++ .../tests/unit/sunbeam/storage/conftest.py | 346 +++----- .../tests/unit/sunbeam/storage/test_base.py | 522 +++++++++++ .../unit/sunbeam/storage/test_hitachi.py | 575 ------------- .../unit/sunbeam/storage/test_manager.py | 377 ++++++++ .../tests/unit/sunbeam/storage/test_models.py | 170 ---- .../unit/sunbeam/storage/test_registry.py | 504 ----------- .../unit/sunbeam/storage/test_service.py | 330 ++++--- .../unit/sunbeam/storage/test_storage_base.py | 296 ------- .../sunbeam/storage/test_storage_steps.py | 594 ------------- 66 files changed, 5109 insertions(+), 6934 deletions(-) create mode 100644 cloud/etc/deploy-storage/main.tf create mode 100644 cloud/etc/deploy-storage/modules/backend/main.tf create mode 100644 cloud/etc/deploy-storage/modules/backend/variables.tf create mode 100644 cloud/etc/deploy-storage/outputs.tf create mode 100644 cloud/etc/deploy-storage/variables.tf create mode 100644 sunbeam-microcluster/api/apitypes/storage_backends.go create mode 100644 sunbeam-microcluster/api/storage_backends.go create mode 100644 sunbeam-microcluster/database/storage_backend.go create mode 100644 sunbeam-microcluster/database/storage_backend.mapper.go create mode 100644 sunbeam-microcluster/sunbeam/storage_backend.go create mode 100644 sunbeam-python/sunbeam/clusterd/models.py delete mode 100644 sunbeam-python/sunbeam/commands/storage.py create mode 100644 sunbeam-python/sunbeam/errors.py delete mode 100644 sunbeam-python/sunbeam/storage/backends/dellsc/cli.py delete mode 100644 sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/main.tf delete mode 100644 sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/outputs.tf delete mode 100644 sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/test-variables.tfvars delete mode 100644 sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/variables.tf create mode 100644 sunbeam-python/sunbeam/storage/backends/hitachi/__init__.py delete mode 100644 sunbeam-python/sunbeam/storage/backends/hitachi/cli.py delete mode 100644 sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/main.tf delete mode 100644 sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/outputs.tf delete mode 100644 sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/test-variables.tfvars delete mode 100644 sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/variables.tf create mode 100644 sunbeam-python/sunbeam/storage/backends/purestorage/__init__.py delete mode 100644 sunbeam-python/sunbeam/storage/backends/purestorage/cli.py delete mode 100644 sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/main.tf delete mode 100644 sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/outputs.tf delete mode 100644 sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/variables.tf create mode 100644 sunbeam-python/sunbeam/storage/cli_base.py create mode 100644 sunbeam-python/sunbeam/storage/manager.py delete mode 100644 sunbeam-python/sunbeam/storage/registry.py rename sunbeam-python/tests/unit/sunbeam/storage/{backends/hitachi => }/__init__.py (66%) create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py delete mode 100644 sunbeam-python/tests/unit/sunbeam/storage/backends/hitachi/test_cli.py create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/backends/test_common.py create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/backends/test_dellsc.py create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/backends/test_hitachi.py create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/backends/test_purestorage.py create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/test_base.py delete mode 100644 sunbeam-python/tests/unit/sunbeam/storage/test_hitachi.py create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/test_manager.py delete mode 100644 sunbeam-python/tests/unit/sunbeam/storage/test_models.py delete mode 100644 sunbeam-python/tests/unit/sunbeam/storage/test_registry.py delete mode 100644 sunbeam-python/tests/unit/sunbeam/storage/test_storage_base.py delete mode 100644 sunbeam-python/tests/unit/sunbeam/storage/test_storage_steps.py diff --git a/cloud/etc/deploy-storage/main.tf b/cloud/etc/deploy-storage/main.tf new file mode 100644 index 000000000..51ceae154 --- /dev/null +++ b/cloud/etc/deploy-storage/main.tf @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +terraform { + required_providers { + juju = { + source = "juju/juju" + version = "= 0.23.1" + } + } +} + +provider "juju" {} + +data "juju_model" "model" { + name = var.model +} + +module "backends" { + for_each = var.backends + + source = "./modules/backend" + + model = data.juju_model.model.uuid + + name = each.key + principal_application = each.value.principal_application + charm_name = each.value.charm_name + charm_base = each.value.charm_base + charm_channel = each.value.charm_channel + charm_revision = each.value.charm_revision + charm_config = each.value.charm_config + endpoint_bindings = each.value.endpoint_bindings + secrets = each.value.secrets +} diff --git a/cloud/etc/deploy-storage/modules/backend/main.tf b/cloud/etc/deploy-storage/modules/backend/main.tf new file mode 100644 index 000000000..6d59d05ec --- /dev/null +++ b/cloud/etc/deploy-storage/modules/backend/main.tf @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +terraform { + required_providers { + juju = { + source = "juju/juju" + } + } +} + +data "juju_model" "model" { + name = var.model +} + +data "juju_application" "cinder-volume" { + name = var.principal_application + model = data.juju_model.model.name +} + +resource "juju_secret" "secret" { + model = data.juju_model.model.name + name = "${var.name}-config-secret" + value = { + for k, v in var.secrets : v => var.charm_config[k] + } +} + +resource "juju_access_secret" "secret-access" { + model = juju_secret.secret.model + secret_id = juju_secret.secret.secret_id + applications = [juju_application.storage-backend.name] +} + +locals { + charm_config = merge( + { volume-backend-name = var.name }, + var.charm_config, + { for k, v in var.secrets : k => juju_secret.secret.secret_uri } + ) +} + +# Deploy Storage backend charms +resource "juju_application" "storage-backend" { + name = var.name + model = data.juju_model.model.uuid + units = 1 + + charm { + name = var.charm_name + channel = var.charm_channel + revision = var.charm_revision + base = var.charm_base + } + + config = local.charm_config + + endpoint_bindings = var.endpoint_bindings +} + +# Integrate Storage backends with cinder-volume +resource "juju_integration" "storage-backend-to-cinder-volume" { + model = data.juju_model.model.name + + application { + name = juju_application.storage-backend.name + endpoint = "cinder-volume" + } + + application { + name = data.juju_application.cinder-volume.name + endpoint = "cinder-volume" + } +} diff --git a/cloud/etc/deploy-storage/modules/backend/variables.tf b/cloud/etc/deploy-storage/modules/backend/variables.tf new file mode 100644 index 000000000..2f2f3f10c --- /dev/null +++ b/cloud/etc/deploy-storage/modules/backend/variables.tf @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +variable "model" { + description = "Name of the machine model to deploy to" + type = string +} + +variable "principal_application" { + description = "Name of the principal application to integrate with" + type = string + default = "cinder-volume" +} + +variable "charm_name" { + description = "Name of the Storage charm" + type = string +} + +variable "charm_base" { + description = "Base for the Storage charm" + type = string + default = "ubuntu@24.04" +} + +variable "charm_channel" { + description = "Operator channel for Storage backend deployment" + type = string + default = "latest/edge" +} + +variable "charm_revision" { + description = "Operator channel revision for Storage backend deployment" + type = number + default = null +} + +variable "name" { + description = "Name of the backend" + type = string +} + +variable "endpoint_bindings" { + description = "Endpoint bindings for the applications" + type = set(map(string)) + default = null +} + +variable "charm_config" { + description = "Operator config for the Storage backend deployment" + type = map(string) + default = {} +} + +variable "secrets" { + description = "Map of secret names to create. The key is the config option name, the value is key to use in the secret dict for the value." + type = map(string) + default = {} +} diff --git a/cloud/etc/deploy-storage/outputs.tf b/cloud/etc/deploy-storage/outputs.tf new file mode 100644 index 000000000..12519b28d --- /dev/null +++ b/cloud/etc/deploy-storage/outputs.tf @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 diff --git a/cloud/etc/deploy-storage/variables.tf b/cloud/etc/deploy-storage/variables.tf new file mode 100644 index 000000000..493261af0 --- /dev/null +++ b/cloud/etc/deploy-storage/variables.tf @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +variable "model" { + description = "UUID of the machine model to deploy to" + type = string +} + +variable "backends" { + description = "Map of storage backend configurations" + type = map(object({ + principal_application = string + charm_name = string + charm_base = string + charm_channel = string + charm_revision = number + charm_config = map(string) + endpoint_bindings = set(map(string)) + secrets = map(string) + })) + default = {} +} diff --git a/sunbeam-microcluster/api/apitypes/storage_backends.go b/sunbeam-microcluster/api/apitypes/storage_backends.go new file mode 100644 index 000000000..92c12a3a7 --- /dev/null +++ b/sunbeam-microcluster/api/apitypes/storage_backends.go @@ -0,0 +1,19 @@ +// Package apitypes provides shared types and structs. +package apitypes + +// StorageBackends holds list of StorageBackend type +type StorageBackends []StorageBackend + +// StorageBackend structure to hold storage backend details like name and type +type StorageBackend struct { + // Name of the storage backend + Name string `json:"name" yaml:"name"` + // Type of the storage backend + Type string `json:"type" yaml:"type"` + // Config holds backend specific configuration as a json blob + Config string `json:"config" yaml:"config"` + // Name of the principal application this storage backend is associated with + Principal string `json:"principal" yaml:"principal"` + // ModelUUID is the juju model UUID where this storage backend is deployed + ModelUUID string `json:"model-uuid" yaml:"model-uuid"` +} diff --git a/sunbeam-microcluster/api/servers.go b/sunbeam-microcluster/api/servers.go index df657d8d2..7e5ad33c6 100644 --- a/sunbeam-microcluster/api/servers.go +++ b/sunbeam-microcluster/api/servers.go @@ -29,6 +29,8 @@ var Servers = map[string]rest.Server{ manifestsCmd, manifestCmd, statusCmd, + storageBackendsCmd, + storageBackendCmd, }, }, { diff --git a/sunbeam-microcluster/api/storage_backends.go b/sunbeam-microcluster/api/storage_backends.go new file mode 100644 index 000000000..2a96738fd --- /dev/null +++ b/sunbeam-microcluster/api/storage_backends.go @@ -0,0 +1,117 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/url" + + "github.com/canonical/lxd/lxd/response" + "github.com/canonical/lxd/shared/api" + "github.com/canonical/microcluster/v2/rest" + "github.com/canonical/microcluster/v2/state" + "github.com/gorilla/mux" + + "github.com/canonical/snap-openstack/sunbeam-microcluster/access" + "github.com/canonical/snap-openstack/sunbeam-microcluster/api/apitypes" + "github.com/canonical/snap-openstack/sunbeam-microcluster/sunbeam" +) + +// /1.0/storage-backend endpoint. +var storageBackendsCmd = rest.Endpoint{ + Path: "storage-backend", + + Get: access.ClusterCATrustedEndpoint(cmdStorageBackendsGetAll, true), + Post: access.ClusterCATrustedEndpoint(cmdStorageBackendsPost, true), +} + +// /1.0/storage-backend/ endpoint. +var storageBackendCmd = rest.Endpoint{ + Path: "storage-backend/{backendname}", + + Get: access.ClusterCATrustedEndpoint(cmdStorageBackendGet, true), + Delete: access.ClusterCATrustedEndpoint(cmdStorageBackendDelete, true), + Put: access.ClusterCATrustedEndpoint(cmdStorageBackendPut, true), +} + +func cmdStorageBackendsGetAll(s state.State, r *http.Request) response.Response { + + storageBackends, err := sunbeam.ListStorageBackends(r.Context(), s) + if err != nil { + return response.InternalError(err) + } + + return response.SyncResponse(true, storageBackends) +} + +func cmdStorageBackendsPost(s state.State, r *http.Request) response.Response { + var req apitypes.StorageBackend + + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + return response.InternalError(err) + } + + err = sunbeam.AddStorageBackend(r.Context(), s, req.Name, req.Type, req.Principal, req.ModelUUID, req.Config) + if err != nil { + return response.InternalError(err) + } + + return response.EmptySyncResponse +} + +func cmdStorageBackendGet(s state.State, r *http.Request) response.Response { + var backendName string + backendName, err := url.PathUnescape(mux.Vars(r)["backendname"]) + if err != nil { + return response.InternalError(err) + } + backend, err := sunbeam.GetStorageBackend(r.Context(), s, backendName) + if err != nil { + if err, ok := err.(api.StatusError); ok { + if err.Status() == http.StatusNotFound { + return response.NotFound(err) + } + } + return response.InternalError(err) + } + + return response.SyncResponse(true, backend) +} + +func cmdStorageBackendDelete(s state.State, r *http.Request) response.Response { + backendName, err := url.PathUnescape(mux.Vars(r)["backendname"]) + if err != nil { + return response.SmartError(err) + } + err = sunbeam.DeleteStorageBackend(r.Context(), s, backendName) + if err != nil { + if err, ok := err.(api.StatusError); ok { + if err.Status() == http.StatusNotFound { + return response.NotFound(err) + } + } + return response.InternalError(err) + } + + return response.EmptySyncResponse +} + +func cmdStorageBackendPut(s state.State, r *http.Request) response.Response { + backendName, err := url.PathUnescape(mux.Vars(r)["backendname"]) + if err != nil { + return response.SmartError(err) + } + + var req apitypes.StorageBackend + err = json.NewDecoder(r.Body).Decode(&req) + if err != nil { + return response.InternalError(err) + } + + err = sunbeam.UpdateStorageBackend(r.Context(), s, backendName, req.Type, req.Config, req.Principal, req.ModelUUID) + if err != nil { + return response.InternalError(err) + } + + return response.EmptySyncResponse +} diff --git a/sunbeam-microcluster/database/schema.go b/sunbeam-microcluster/database/schema.go index 75235b591..1c9924100 100644 --- a/sunbeam-microcluster/database/schema.go +++ b/sunbeam-microcluster/database/schema.go @@ -16,6 +16,7 @@ var SchemaExtensions = []schema.Update{ JujuUserSchemaUpdate, ManifestsSchemaUpdate, AddSystemIDToNodes, + StorageBackendSchemaUpdate, } // NodesSchemaUpdate is schema for table nodes @@ -96,3 +97,21 @@ ALTER TABLE nodes ADD COLUMN system_id TEXT default ''; return err } + +// StorageBackendSchemaUpdate is schema for table storage_backends +func StorageBackendSchemaUpdate(_ context.Context, tx *sql.Tx) error { + stmt := ` +CREATE TABLE storage_backends ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + principal TEXT, + model_uuid TEXT, + config TEXT, + UNIQUE(name) +); + ` + + _, err := tx.Exec(stmt) + return err +} diff --git a/sunbeam-microcluster/database/storage_backend.go b/sunbeam-microcluster/database/storage_backend.go new file mode 100644 index 000000000..1e4d02392 --- /dev/null +++ b/sunbeam-microcluster/database/storage_backend.go @@ -0,0 +1,40 @@ +package database + +//go:generate -command mapper lxd-generate db mapper -t storage_backend.mapper.go +//go:generate mapper reset +// +//go:generate mapper stmt -d github.com/canonical/microcluster/v2/cluster -e StorageBackend objects table=storage_backends +//go:generate mapper stmt -d github.com/canonical/microcluster/v2/cluster -e StorageBackend objects-by-Name table=storage_backends +//go:generate mapper stmt -d github.com/canonical/microcluster/v2/cluster -e StorageBackend objects-by-Type table=storage_backends +//go:generate mapper stmt -d github.com/canonical/microcluster/v2/cluster -e StorageBackend objects-by-Principal table=storage_backends +//go:generate mapper stmt -d github.com/canonical/microcluster/v2/cluster -e StorageBackend objects-by-ModelUUID table=storage_backends +//go:generate mapper stmt -d github.com/canonical/microcluster/v2/cluster -e StorageBackend id table=storage_backends +//go:generate mapper stmt -d github.com/canonical/microcluster/v2/cluster -e StorageBackend create table=storage_backends +//go:generate mapper stmt -d github.com/canonical/microcluster/v2/cluster -e StorageBackend delete-by-Name table=storage_backends +//go:generate mapper stmt -d github.com/canonical/microcluster/v2/cluster -e StorageBackend update table=storage_backends +// +//go:generate mapper method -i -d github.com/canonical/microcluster/v2/cluster -e StorageBackend GetMany +//go:generate mapper method -i -d github.com/canonical/microcluster/v2/cluster -e StorageBackend GetOne +//go:generate mapper method -i -d github.com/canonical/microcluster/v2/cluster -e StorageBackend ID +//go:generate mapper method -i -d github.com/canonical/microcluster/v2/cluster -e StorageBackend Exists +//go:generate mapper method -i -d github.com/canonical/microcluster/v2/cluster -e StorageBackend Create +//go:generate mapper method -i -d github.com/canonical/microcluster/v2/cluster -e StorageBackend DeleteOne-by-Name +//go:generate mapper method -i -d github.com/canonical/microcluster/v2/cluster -e StorageBackend Update + +// StorageBackend is used to track StorageBackend information. +type StorageBackend struct { + ID int + Name string `db:"primary=yes"` + Type string + Config string + Principal string + ModelUUID string +} + +// StorageBackendFilter is a required struct for use with lxd-generate. It is used for filtering fields on database fetches. +type StorageBackendFilter struct { + Name *string + Type *string + Principal *string + ModelUUID *string +} diff --git a/sunbeam-microcluster/database/storage_backend.mapper.go b/sunbeam-microcluster/database/storage_backend.mapper.go new file mode 100644 index 000000000..231c0758e --- /dev/null +++ b/sunbeam-microcluster/database/storage_backend.mapper.go @@ -0,0 +1,422 @@ +package database + +// The code below was generated by lxd-generate - DO NOT EDIT! + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/canonical/lxd/lxd/db/query" + "github.com/canonical/lxd/shared/api" + "github.com/canonical/microcluster/v2/cluster" +) + +var _ = api.ServerEnvironment{} + +var storageBackendObjects = cluster.RegisterStmt(` +SELECT storage_backends.id, storage_backends.name, storage_backends.type, storage_backends.config, storage_backends.principal, storage_backends.model_uuid + FROM storage_backends + ORDER BY storage_backends.name +`) + +var storageBackendObjectsByName = cluster.RegisterStmt(` +SELECT storage_backends.id, storage_backends.name, storage_backends.type, storage_backends.config, storage_backends.principal, storage_backends.model_uuid + FROM storage_backends + WHERE ( storage_backends.name = ? ) + ORDER BY storage_backends.name +`) + +var storageBackendObjectsByType = cluster.RegisterStmt(` +SELECT storage_backends.id, storage_backends.name, storage_backends.type, storage_backends.config, storage_backends.principal, storage_backends.model_uuid + FROM storage_backends + WHERE ( storage_backends.type = ? ) + ORDER BY storage_backends.name +`) + +var storageBackendObjectsByPrincipal = cluster.RegisterStmt(` +SELECT storage_backends.id, storage_backends.name, storage_backends.type, storage_backends.config, storage_backends.principal, storage_backends.model_uuid + FROM storage_backends + WHERE ( storage_backends.principal = ? ) + ORDER BY storage_backends.name +`) + +var storageBackendObjectsByModelUUID = cluster.RegisterStmt(` +SELECT storage_backends.id, storage_backends.name, storage_backends.type, storage_backends.config, storage_backends.principal, storage_backends.model_uuid + FROM storage_backends + WHERE ( storage_backends.model_uuid = ? ) + ORDER BY storage_backends.name +`) + +var storageBackendID = cluster.RegisterStmt(` +SELECT storage_backends.id FROM storage_backends + WHERE storage_backends.name = ? +`) + +var storageBackendCreate = cluster.RegisterStmt(` +INSERT INTO storage_backends (name, type, config, principal, model_uuid) + VALUES (?, ?, ?, ?, ?) +`) + +var storageBackendDeleteByName = cluster.RegisterStmt(` +DELETE FROM storage_backends WHERE name = ? +`) + +var storageBackendUpdate = cluster.RegisterStmt(` +UPDATE storage_backends + SET name = ?, type = ?, config = ?, principal = ?, model_uuid = ? + WHERE id = ? +`) + +// storageBackendColumns returns a string of column names to be used with a SELECT statement for the entity. +// Use this function when building statements to retrieve database entries matching the StorageBackend entity. +func storageBackendColumns() string { + return "storage_backends.id, storage_backends.name, storage_backends.type, storage_backends.config, storage_backends.principal, storage_backends.model_uuid" +} + +// getStorageBackends can be used to run handwritten sql.Stmts to return a slice of objects. +func getStorageBackends(ctx context.Context, stmt *sql.Stmt, args ...any) ([]StorageBackend, error) { + objects := make([]StorageBackend, 0) + + dest := func(scan func(dest ...any) error) error { + s := StorageBackend{} + err := scan(&s.ID, &s.Name, &s.Type, &s.Config, &s.Principal, &s.ModelUUID) + if err != nil { + return err + } + + objects = append(objects, s) + + return nil + } + + err := query.SelectObjects(ctx, stmt, dest, args...) + if err != nil { + return nil, fmt.Errorf("Failed to fetch from \"storage_backends\" table: %w", err) + } + + return objects, nil +} + +// getStorageBackendsRaw can be used to run handwritten query strings to return a slice of objects. +func getStorageBackendsRaw(ctx context.Context, tx *sql.Tx, sql string, args ...any) ([]StorageBackend, error) { + objects := make([]StorageBackend, 0) + + dest := func(scan func(dest ...any) error) error { + s := StorageBackend{} + err := scan(&s.ID, &s.Name, &s.Type, &s.Config, &s.Principal, &s.ModelUUID) + if err != nil { + return err + } + + objects = append(objects, s) + + return nil + } + + err := query.Scan(ctx, tx, sql, dest, args...) + if err != nil { + return nil, fmt.Errorf("Failed to fetch from \"storage_backends\" table: %w", err) + } + + return objects, nil +} + +// GetStorageBackends returns all available StorageBackends. +// generator: StorageBackend GetMany +func GetStorageBackends(ctx context.Context, tx *sql.Tx, filters ...StorageBackendFilter) ([]StorageBackend, error) { + var err error + + // Result slice. + objects := make([]StorageBackend, 0) + + // Pick the prepared statement and arguments to use based on active criteria. + var sqlStmt *sql.Stmt + args := []any{} + queryParts := [2]string{} + + if len(filters) == 0 { + sqlStmt, err = cluster.Stmt(tx, storageBackendObjects) + if err != nil { + return nil, fmt.Errorf("Failed to get \"storageBackendObjects\" prepared statement: %w", err) + } + } + + for i, filter := range filters { + if filter.Type != nil && filter.Name == nil && filter.Principal == nil && filter.ModelUUID == nil { + args = append(args, []any{filter.Type}...) + if len(filters) == 1 { + sqlStmt, err = cluster.Stmt(tx, storageBackendObjectsByType) + if err != nil { + return nil, fmt.Errorf("Failed to get \"storageBackendObjectsByType\" prepared statement: %w", err) + } + + break + } + + query, err := cluster.StmtString(storageBackendObjectsByType) + if err != nil { + return nil, fmt.Errorf("Failed to get \"storageBackendObjects\" prepared statement: %w", err) + } + + parts := strings.SplitN(query, "ORDER BY", 2) + if i == 0 { + copy(queryParts[:], parts) + continue + } + + _, where, _ := strings.Cut(parts[0], "WHERE") + queryParts[0] += "OR" + where + } else if filter.Principal != nil && filter.Name == nil && filter.Type == nil && filter.ModelUUID == nil { + args = append(args, []any{filter.Principal}...) + if len(filters) == 1 { + sqlStmt, err = cluster.Stmt(tx, storageBackendObjectsByPrincipal) + if err != nil { + return nil, fmt.Errorf("Failed to get \"storageBackendObjectsByPrincipal\" prepared statement: %w", err) + } + + break + } + + query, err := cluster.StmtString(storageBackendObjectsByPrincipal) + if err != nil { + return nil, fmt.Errorf("Failed to get \"storageBackendObjects\" prepared statement: %w", err) + } + + parts := strings.SplitN(query, "ORDER BY", 2) + if i == 0 { + copy(queryParts[:], parts) + continue + } + + _, where, _ := strings.Cut(parts[0], "WHERE") + queryParts[0] += "OR" + where + } else if filter.Name != nil && filter.Type == nil && filter.Principal == nil && filter.ModelUUID == nil { + args = append(args, []any{filter.Name}...) + if len(filters) == 1 { + sqlStmt, err = cluster.Stmt(tx, storageBackendObjectsByName) + if err != nil { + return nil, fmt.Errorf("Failed to get \"storageBackendObjectsByName\" prepared statement: %w", err) + } + + break + } + + query, err := cluster.StmtString(storageBackendObjectsByName) + if err != nil { + return nil, fmt.Errorf("Failed to get \"storageBackendObjects\" prepared statement: %w", err) + } + + parts := strings.SplitN(query, "ORDER BY", 2) + if i == 0 { + copy(queryParts[:], parts) + continue + } + + _, where, _ := strings.Cut(parts[0], "WHERE") + queryParts[0] += "OR" + where + } else if filter.ModelUUID != nil && filter.Name == nil && filter.Type == nil && filter.Principal == nil { + args = append(args, []any{filter.ModelUUID}...) + if len(filters) == 1 { + sqlStmt, err = cluster.Stmt(tx, storageBackendObjectsByModelUUID) + if err != nil { + return nil, fmt.Errorf("Failed to get \"storageBackendObjectsByModelUUID\" prepared statement: %w", err) + } + + break + } + + query, err := cluster.StmtString(storageBackendObjectsByModelUUID) + if err != nil { + return nil, fmt.Errorf("Failed to get \"storageBackendObjects\" prepared statement: %w", err) + } + + parts := strings.SplitN(query, "ORDER BY", 2) + if i == 0 { + copy(queryParts[:], parts) + continue + } + + _, where, _ := strings.Cut(parts[0], "WHERE") + queryParts[0] += "OR" + where + } else if filter.Name == nil && filter.Type == nil && filter.Principal == nil && filter.ModelUUID == nil { + return nil, fmt.Errorf("Cannot filter on empty StorageBackendFilter") + } else { + return nil, fmt.Errorf("No statement exists for the given Filter") + } + } + + // Select. + if sqlStmt != nil { + objects, err = getStorageBackends(ctx, sqlStmt, args...) + } else { + queryStr := strings.Join(queryParts[:], "ORDER BY") + objects, err = getStorageBackendsRaw(ctx, tx, queryStr, args...) + } + + if err != nil { + return nil, fmt.Errorf("Failed to fetch from \"storage_backends\" table: %w", err) + } + + return objects, nil +} + +// GetStorageBackend returns the StorageBackend with the given key. +// generator: StorageBackend GetOne +func GetStorageBackend(ctx context.Context, tx *sql.Tx, name string) (*StorageBackend, error) { + filter := StorageBackendFilter{} + filter.Name = &name + + objects, err := GetStorageBackends(ctx, tx, filter) + if err != nil { + return nil, fmt.Errorf("Failed to fetch from \"storage_backends\" table: %w", err) + } + + switch len(objects) { + case 0: + return nil, api.StatusErrorf(http.StatusNotFound, "StorageBackend not found") + case 1: + return &objects[0], nil + default: + return nil, fmt.Errorf("More than one \"storage_backends\" entry matches") + } +} + +// GetStorageBackendID return the ID of the StorageBackend with the given key. +// generator: StorageBackend ID +func GetStorageBackendID(ctx context.Context, tx *sql.Tx, name string) (int64, error) { + stmt, err := cluster.Stmt(tx, storageBackendID) + if err != nil { + return -1, fmt.Errorf("Failed to get \"storageBackendID\" prepared statement: %w", err) + } + + row := stmt.QueryRowContext(ctx, name) + var id int64 + err = row.Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return -1, api.StatusErrorf(http.StatusNotFound, "StorageBackend not found") + } + + if err != nil { + return -1, fmt.Errorf("Failed to get \"storage_backends\" ID: %w", err) + } + + return id, nil +} + +// StorageBackendExists checks if a StorageBackend with the given key exists. +// generator: StorageBackend Exists +func StorageBackendExists(ctx context.Context, tx *sql.Tx, name string) (bool, error) { + _, err := GetStorageBackendID(ctx, tx, name) + if err != nil { + if api.StatusErrorCheck(err, http.StatusNotFound) { + return false, nil + } + + return false, err + } + + return true, nil +} + +// CreateStorageBackend adds a new StorageBackend to the database. +// generator: StorageBackend Create +func CreateStorageBackend(ctx context.Context, tx *sql.Tx, object StorageBackend) (int64, error) { + // Check if a StorageBackend with the same key exists. + exists, err := StorageBackendExists(ctx, tx, object.Name) + if err != nil { + return -1, fmt.Errorf("Failed to check for duplicates: %w", err) + } + + if exists { + return -1, api.StatusErrorf(http.StatusConflict, "This \"storage_backends\" entry already exists") + } + + args := make([]any, 5) + + // Populate the statement arguments. + args[0] = object.Name + args[1] = object.Type + args[2] = object.Config + args[3] = object.Principal + args[4] = object.ModelUUID + + // Prepared statement to use. + stmt, err := cluster.Stmt(tx, storageBackendCreate) + if err != nil { + return -1, fmt.Errorf("Failed to get \"storageBackendCreate\" prepared statement: %w", err) + } + + // Execute the statement. + result, err := stmt.Exec(args...) + if err != nil { + return -1, fmt.Errorf("Failed to create \"storage_backends\" entry: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return -1, fmt.Errorf("Failed to fetch \"storage_backends\" entry ID: %w", err) + } + + return id, nil +} + +// DeleteStorageBackend deletes the StorageBackend matching the given key parameters. +// generator: StorageBackend DeleteOne-by-Name +func DeleteStorageBackend(_ context.Context, tx *sql.Tx, name string) error { + stmt, err := cluster.Stmt(tx, storageBackendDeleteByName) + if err != nil { + return fmt.Errorf("Failed to get \"storageBackendDeleteByName\" prepared statement: %w", err) + } + + result, err := stmt.Exec(name) + if err != nil { + return fmt.Errorf("Delete \"storage_backends\": %w", err) + } + + n, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("Fetch affected rows: %w", err) + } + + if n == 0 { + return api.StatusErrorf(http.StatusNotFound, "StorageBackend not found") + } else if n > 1 { + return fmt.Errorf("Query deleted %d StorageBackend rows instead of 1", n) + } + + return nil +} + +// UpdateStorageBackend updates the StorageBackend matching the given key parameters. +// generator: StorageBackend Update +func UpdateStorageBackend(ctx context.Context, tx *sql.Tx, name string, object StorageBackend) error { + id, err := GetStorageBackendID(ctx, tx, name) + if err != nil { + return err + } + + stmt, err := cluster.Stmt(tx, storageBackendUpdate) + if err != nil { + return fmt.Errorf("Failed to get \"storageBackendUpdate\" prepared statement: %w", err) + } + + result, err := stmt.Exec(object.Name, object.Type, object.Config, object.Principal, object.ModelUUID, id) + if err != nil { + return fmt.Errorf("Update \"storage_backends\" entry failed: %w", err) + } + + n, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("Fetch affected rows: %w", err) + } + + if n != 1 { + return fmt.Errorf("Query updated %d rows instead of 1", n) + } + + return nil +} diff --git a/sunbeam-microcluster/sunbeam/storage_backend.go b/sunbeam-microcluster/sunbeam/storage_backend.go new file mode 100644 index 000000000..9f4cdf79d --- /dev/null +++ b/sunbeam-microcluster/sunbeam/storage_backend.go @@ -0,0 +1,127 @@ +package sunbeam + +import ( + "context" + "database/sql" + "fmt" + + "github.com/canonical/microcluster/v2/state" + + "github.com/canonical/snap-openstack/sunbeam-microcluster/api/apitypes" + "github.com/canonical/snap-openstack/sunbeam-microcluster/database" +) + +// ListStorageBackends return all the storage backends, filterable by role (Optional) +func ListStorageBackends(ctx context.Context, s state.State) (apitypes.StorageBackends, error) { + backends := apitypes.StorageBackends{} + + // Get the storage backends from the database. + err := s.Database().Transaction(ctx, func(ctx context.Context, tx *sql.Tx) error { + records, err := database.GetStorageBackends(ctx, tx) + if err != nil { + return fmt.Errorf("Failed to fetch storage backends: %w", err) + } + + for _, backend := range records { + + backends = append(backends, apitypes.StorageBackend{ + Name: backend.Name, + Type: backend.Type, + Principal: backend.Principal, + ModelUUID: backend.ModelUUID, + Config: backend.Config, + }) + } + + return nil + }) + if err != nil { + return nil, err + } + + return backends, nil + +} + +// GetStorageBackend returns a StorageBackend with the given name +func GetStorageBackend(ctx context.Context, s state.State, name string) (apitypes.StorageBackend, error) { + backend := apitypes.StorageBackend{} + err := s.Database().Transaction(ctx, func(ctx context.Context, tx *sql.Tx) error { + record, err := database.GetStorageBackend(ctx, tx, name) + if err != nil { + return err + } + + backend.Name = record.Name + backend.Type = record.Type + backend.Principal = record.Principal + backend.ModelUUID = record.ModelUUID + backend.Config = record.Config + + return nil + }) + if err != nil { + return apitypes.StorageBackend{}, err + } + return backend, nil +} + +// AddStorageBackend adds a storage backend to the database +func AddStorageBackend(ctx context.Context, s state.State, name string, backendType string, principal string, modelUUID string, config string) error { + // Add storage backend to the database. + return s.Database().Transaction(ctx, func(ctx context.Context, tx *sql.Tx) error { + _, err := database.CreateStorageBackend(ctx, tx, database.StorageBackend{ + Name: name, + Type: backendType, + Principal: principal, + ModelUUID: modelUUID, + Config: config, + }) + if err != nil { + return fmt.Errorf("Failed to record storage backend: %w", err) + } + + return nil + }) +} + +// UpdateStorageBackend updates a storage backend record in the database +func UpdateStorageBackend(ctx context.Context, s state.State, name string, backendType string, principal string, modelUUID string, config string) error { + // Update storage backend to the database. + err := s.Database().Transaction(ctx, func(ctx context.Context, tx *sql.Tx) error { + backend, err := database.GetStorageBackend(ctx, tx, name) + if err != nil { + return fmt.Errorf("Failed to retrieve storage backend details: %w", err) + } + + if backendType == "" { + backendType = backend.Type + } + if principal == "" { + principal = backend.Principal + } + if modelUUID == "" { + modelUUID = backend.ModelUUID + } + if config == "" { + config = backend.Config + } + + err = database.UpdateStorageBackend(ctx, tx, name, database.StorageBackend{Name: name, Type: backendType, Principal: principal, ModelUUID: modelUUID, Config: config}) + if err != nil { + return fmt.Errorf("Failed to update record storage backend: %w", err) + } + + return nil + }) + + return err +} + +// DeleteStorageBackend deletes a storage backend from database +func DeleteStorageBackend(ctx context.Context, s state.State, name string) error { + return s.Database().Transaction(ctx, func(ctx context.Context, tx *sql.Tx) error { + return database.DeleteStorageBackend(ctx, tx, name) + }) + +} diff --git a/sunbeam-python/sunbeam/clusterd/cluster.py b/sunbeam-python/sunbeam/clusterd/cluster.py index 51990f3f5..7503b4fb0 100644 --- a/sunbeam-python/sunbeam/clusterd/cluster.py +++ b/sunbeam-python/sunbeam/clusterd/cluster.py @@ -9,7 +9,7 @@ from requests import codes from requests.models import HTTPError -from sunbeam.clusterd import service +from sunbeam.clusterd import models, service LOG = logging.getLogger(__name__) @@ -239,6 +239,58 @@ def get_status(self) -> dict[str, dict]: for member in members } + def get_storage_backends(self) -> models.StorageBackends: + """List all storage backends.""" + backends = self._get("/1.0/storage-backend") + return models.StorageBackends(root=backends.get("metadata", [])) + + def get_storage_backend(self, name: str) -> models.StorageBackend: + """Get storage backend by name.""" + backend = self._get(f"/1.0/storage-backend/{name}") + return models.StorageBackend(**backend.get("metadata", {})) + + def add_storage_backend( + self, + name: str, + backend_type: str, + config: dict[str, Any], + principal: str, + model_uuid: str, + ) -> None: + """Add a new storage backend.""" + data = { + "name": name, + "type": backend_type, + "config": json.dumps(config), + "principal": principal, + "model-uuid": model_uuid, + } + self._post("/1.0/storage-backend", data=json.dumps(data)) + + def delete_storage_backend(self, name: str) -> None: + """Delete storage backend by name.""" + self._delete(f"/1.0/storage-backend/{name}") + + def update_storage_backend( + self, + name: str, + backend_type: str | None = None, + config: dict[str, Any] | None = None, + principal: str | None = None, + model_uuid: str | None = None, + ) -> None: + """Update an existing storage backend.""" + data: dict[str, Any] = {} + if backend_type is not None: + data["type"] = backend_type + if config is not None: + data["config"] = json.dumps(config) + if principal is not None: + data["principal"] = principal + if model_uuid is not None: + data["model-uuid"] = model_uuid + self._put(f"/1.0/storage-backend/{name}", data=json.dumps(data)) + class ClusterService(MicroClusterService, ExtendedAPIService): """Lists and manages cluster.""" diff --git a/sunbeam-python/sunbeam/clusterd/models.py b/sunbeam-python/sunbeam/clusterd/models.py new file mode 100644 index 000000000..3e41228e0 --- /dev/null +++ b/sunbeam-python/sunbeam/clusterd/models.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Clusterd models for Sunbeam.""" + +import typing + +import pydantic + +from sunbeam import utils + + +class StorageBackend(pydantic.BaseModel): + """Storage backend model.""" + + model_config = pydantic.ConfigDict( + alias_generator=pydantic.AliasGenerator( + validation_alias=utils.to_kebab, + serialization_alias=utils.to_kebab, + ), + ) + name: str + type: str + config: pydantic.Json[dict[str, typing.Any]] + principal: str + model_uuid: str + + +class StorageBackends(pydantic.RootModel[list[StorageBackend]]): + """Storage backends model.""" diff --git a/sunbeam-python/sunbeam/clusterd/service.py b/sunbeam-python/sunbeam/clusterd/service.py index 8ae9e6fa7..cf360a89d 100644 --- a/sunbeam-python/sunbeam/clusterd/service.py +++ b/sunbeam-python/sunbeam/clusterd/service.py @@ -92,6 +92,14 @@ class URLNotFoundException(RemoteException): pass +class StorageBackendException(RemoteException): + """Base exception for storage backend operations.""" + + +class StorageBackendNotFoundException(StorageBackendException): + """Raised when storage backend is not found.""" + + class BaseService(ABC): """BaseService is the base service class for sunbeam clusterd services.""" @@ -203,6 +211,8 @@ def _request(self, method, path, **kwargs): # noqa: C901 too complex raise ConfigItemNotFoundException("ConfigItem not found") elif "ManifestItem not found" in error: raise ManifestItemNotFoundException("ManifestItem not found") + elif "StorageBackend not found" in error: + raise StorageBackendNotFoundException("Storage backend not found") raise e return response.json() diff --git a/sunbeam-python/sunbeam/commands/storage.py b/sunbeam-python/sunbeam/commands/storage.py deleted file mode 100644 index 34fa9c3bd..000000000 --- a/sunbeam-python/sunbeam/commands/storage.py +++ /dev/null @@ -1,46 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -import logging - -import click -from rich.console import Console - -from sunbeam.core.deployment import Deployment -from sunbeam.storage.registry import StorageBackendRegistry - -LOG = logging.getLogger(__name__) -console = Console() - -CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} - - -@click.group("storage", context_settings=CONTEXT_SETTINGS) -@click.pass_context -def storage(ctx): - """Manage Cinder storage backends. - - Provides commands to add, remove, configure and list storage backends. - Supports multiple backend types including Hitachi VSP and others. - """ - # Ensure we have a deployment object - if not hasattr(ctx, "obj") or not isinstance(ctx.obj, Deployment): - raise click.ClickException( - "Storage commands require a valid deployment context. " - "Please ensure sunbeam is properly initialized." - ) - - -def register_storage_commands(deployment: Deployment) -> None: - """Register storage backend commands with the storage group. - - This function is called from main.py to register all storage backend - commands dynamically based on available backends. - """ - try: - registry = StorageBackendRegistry() - registry.register_cli_commands(storage, deployment) - LOG.debug("Storage backend commands registered successfully") - except Exception as e: - LOG.error(f"Failed to register storage backend commands: {e}") - # Don't raise here as we want the CLI to still work diff --git a/sunbeam-python/sunbeam/core/common.py b/sunbeam-python/sunbeam/core/common.py index 6f31c1c6a..d0519ef47 100644 --- a/sunbeam-python/sunbeam/core/common.py +++ b/sunbeam-python/sunbeam/core/common.py @@ -21,6 +21,7 @@ from tenacity import RetryCallState from sunbeam.clusterd.client import Client +from sunbeam.errors import SunbeamException # noqa F401 LOG = logging.getLogger(__name__) RAM_16_GB_IN_KB = 16 * 1000 * 1000 @@ -516,12 +517,6 @@ def convert_proxy_to_model_configs(proxy_settings: dict) -> dict: } -class SunbeamException(Exception): - """Base exception for sunbeam.""" - - pass - - class RiskLevel(str, enum.Enum): STABLE = "stable" CANDIDATE = "candidate" diff --git a/sunbeam-python/sunbeam/core/deployment.py b/sunbeam-python/sunbeam/core/deployment.py index bff4d8fa0..155e7c712 100644 --- a/sunbeam-python/sunbeam/core/deployment.py +++ b/sunbeam-python/sunbeam/core/deployment.py @@ -31,6 +31,9 @@ FeatureGroupManifest, FeatureManifest, Manifest, + StorageBackendManifests, + StorageInstanceManifest, + StorageManifest, embedded_manifest_path, ) from sunbeam.core.proxy import patch_process_env, should_bypass @@ -40,9 +43,11 @@ if TYPE_CHECKING: from sunbeam.feature_manager import FeatureManager from sunbeam.features.interface.v1.base import BaseFeature + from sunbeam.storage.manager import StorageBackendManager else: FeatureManager = object BaseFeature = object + StorageBackendManager = object LOG = logging.getLogger(__name__) PROXY_CONFIG_KEY = "ProxySettings" @@ -98,6 +103,7 @@ class Deployment(pydantic.BaseModel): _manifest: Manifest | None = pydantic.PrivateAttr(default=None) _tfhelpers: dict[str, TerraformHelper] = pydantic.PrivateAttr(default={}) _feature_manager: FeatureManager | None = pydantic.PrivateAttr(default=None) + _storage_manager: StorageBackendManager | None = pydantic.PrivateAttr(default=None) @property def openstack_machines_model(self) -> str: @@ -188,6 +194,15 @@ def get_feature_manager(self) -> "FeatureManager": return self._feature_manager + def get_storage_manager(self) -> "StorageBackendManager": + """Return the storage backend manager for the deployment.""" + from sunbeam.storage.manager import StorageBackendManager + + if self._storage_manager is None: + self._storage_manager = StorageBackendManager() + + return self._storage_manager + def get_proxy_settings(self) -> dict: """Fetch proxy settings from clusterd, if not available use defaults.""" proxy = {} @@ -269,12 +284,44 @@ def _parse_feature( return feature_manifests + def parse_storage_manifest( + self, storage_manifest_data: dict[str, dict] + ) -> StorageManifest: + """Parse storage manifest data.""" + if not storage_manifest_data: + return StorageManifest(root={}) + backends = self.get_storage_manager().backends() + + storage_manifest: StorageManifest = StorageManifest(root={}) + for backend_type, backends_dict in storage_manifest_data.items(): + backend = backends[backend_type] + backend_config_type = backend.config_type() + backends_config = storage_manifest.root.setdefault( + backend_type, StorageBackendManifests(root={}) + ) + for name, manifest_dict in backends_dict.items(): + manifest_dict_config = manifest_dict.pop("config", None) + backends_config.root[name] = StorageInstanceManifest.model_validate( + manifest_dict, by_alias=True + ) + if manifest_dict_config: + backends_config.root[ + name + ].config = backend_config_type.model_validate( + manifest_dict_config, by_alias=True + ) + + return storage_manifest + def parse_manifest(self, manifest_data: dict) -> Manifest: """Parse manifest data.""" features = manifest_data.pop("features", {}) + storage = manifest_data.pop("storage", {}) manifest = Manifest.model_validate(manifest_data) if features: manifest.features = self.parse_feature_manifest(features) + if storage: + manifest.storage = self.parse_storage_manifest(storage) return manifest def get_manifest(self, manifest_file: pathlib.Path | None = None) -> Manifest: diff --git a/sunbeam-python/sunbeam/core/manifest.py b/sunbeam-python/sunbeam/core/manifest.py index 4e615581b..fcad149ed 100644 --- a/sunbeam-python/sunbeam/core/manifest.py +++ b/sunbeam-python/sunbeam/core/manifest.py @@ -145,6 +145,26 @@ class FeatureConfig(pydantic.BaseModel): pass +class StorageBackendConfig(pydantic.BaseModel): + """Base configuration model for storage backends.""" + + model_config = pydantic.ConfigDict( + alias_generator=pydantic.AliasGenerator( + validation_alias=utils.to_kebab, + serialization_alias=utils.to_kebab, + ), + ) + + volume_backend_name: typing.Annotated[ + str | None, + Field(description="Name that Cinder will report for this backend"), + ] = None + backend_availability_zone: typing.Annotated[ + str | None, + Field(description="Availability zone to associate with this backend"), + ] = None + + def _default_software_config() -> SoftwareConfig: snap = Snap() return SoftwareConfig( @@ -287,15 +307,18 @@ def merge(self, other: "CoreManifest") -> "CoreManifest": return type(self)(config=config, software=software) -class FeatureManifest(pydantic.BaseModel): - config: pydantic.SerializeAsAny[FeatureConfig] | None = None +T = typing.TypeVar("T", bound=pydantic.BaseModel) + + +class _AddonManifest(pydantic.BaseModel, typing.Generic[T]): + config: pydantic.SerializeAsAny[T] | None = None software: SoftwareConfig = SoftwareConfig() - def merge(self, other: "FeatureManifest") -> "FeatureManifest": - """Merge the feature manifest with the provided manifest.""" + def merge(self, other: "typing.Self") -> "typing.Self": + """Merge the addon manifest with the provided manifest.""" if self.config and other.config: if type(self.config) is not type(other.config): - raise ValueError("Feature config types do not match") + raise ValueError("Config types do not match") config = type(self.config).model_validate( utils.merge_dict( self.config.model_dump(by_alias=True), @@ -312,6 +335,30 @@ def merge(self, other: "FeatureManifest") -> "FeatureManifest": return type(self)(config=config, software=software) +class FeatureManifest(_AddonManifest[FeatureConfig]): + pass + + +class StorageInstanceManifest(_AddonManifest[StorageBackendConfig]): + pass + + +class StorageBackendManifests(pydantic.RootModel[dict[str, StorageInstanceManifest]]): + """Storage backend manifests. + + Key: Instance name + Value: Storage backend manifest + """ + + +class StorageManifest(pydantic.RootModel[dict[str, StorageBackendManifests]]): + """Storage manifest containing all storage backends. + + Key: Storage type + Value: Storage backend manifests + """ + + class FeatureGroupManifest(pydantic.RootModel[dict[str, FeatureManifest]]): def merge(self, other: "FeatureGroupManifest") -> "FeatureGroupManifest": """Merge the feature group manifest with the provided manifest.""" @@ -336,6 +383,7 @@ def validate_againt_default(self, default_manifest: "FeatureGroupManifest") -> N class Manifest(pydantic.BaseModel): core: CoreManifest = pydantic.Field(default_factory=CoreManifest) features: dict[str, FeatureManifest | FeatureGroupManifest] = {} + storage: StorageManifest = StorageManifest(root={}) def get_features(self) -> typing.Generator[tuple[str, FeatureManifest], None, None]: """Return all the features.""" @@ -370,6 +418,8 @@ def from_file(cls, file: Path) -> "Manifest": def merge(self, other: "Manifest") -> "Manifest": """Merge the manifest with the provided manifest.""" core = self.core.merge(other.core) + # Storage has no defaults, and will be fully replaced + storage = other.storage features: dict[str, FeatureManifest | FeatureGroupManifest] = {} for feature, feature_or_group_manifest in self.features.items(): if other_manifest := other.features.get(feature): @@ -384,7 +434,7 @@ def merge(self, other: "Manifest") -> "Manifest": else: features[feature] = feature_or_group_manifest - return type(self)(core=core, features=features) + return type(self)(core=core, features=features, storage=storage) def validate_against_default(self, default_manifest: "Manifest") -> None: """Validate the manifest against the default manifest.""" diff --git a/sunbeam-python/sunbeam/errors.py b/sunbeam-python/sunbeam/errors.py new file mode 100644 index 000000000..f944c42e0 --- /dev/null +++ b/sunbeam-python/sunbeam/errors.py @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + + +class SunbeamException(Exception): + """Base exception for sunbeam.""" diff --git a/sunbeam-python/sunbeam/main.py b/sunbeam-python/sunbeam/main.py index 0debe1681..48cc8ff5b 100644 --- a/sunbeam-python/sunbeam/main.py +++ b/sunbeam-python/sunbeam/main.py @@ -20,7 +20,6 @@ from sunbeam.commands import prepare_node as prepare_node_cmds from sunbeam.commands import proxy as proxy_cmds from sunbeam.commands import sso as sso_cmd -from sunbeam.commands import storage as storage_cmds from sunbeam.commands import utils as utils_cmds from sunbeam.core import deployments as deployments_jobs from sunbeam.provider import commands as provider_cmds @@ -114,7 +113,6 @@ def main(): cli.add_command(launch_cmds.launch) cli.add_command(openrc_cmds.openrc) cli.add_command(dasboard_url_cmds.dashboard_url) - cli.add_command(storage_cmds.storage) # Add identity group cli.add_command(identity_group) @@ -163,7 +161,7 @@ def main(): juju.add_command(juju_cmds.unregister_controller) # Register storage backend commands - storage_cmds.register_storage_commands(deployment) + deployment.get_storage_manager().register(cli, deployment) # Register the features after all groups,commands are registered deployment.get_feature_manager().register(cli, deployment) diff --git a/sunbeam-python/sunbeam/storage/backends/dellsc/__init__.py b/sunbeam-python/sunbeam/storage/backends/dellsc/__init__.py index dbc28643e..68f03f984 100644 --- a/sunbeam-python/sunbeam/storage/backends/dellsc/__init__.py +++ b/sunbeam-python/sunbeam/storage/backends/dellsc/__init__.py @@ -2,8 +2,3 @@ # SPDX-License-Identifier: Apache-2.0 """Dell Storage Center backend for Sunbeam storage.""" - -from .backend import DellSCBackend, DellSCConfig -from .cli import DellscCLI - -__all__ = ["DellSCBackend", "DellSCConfig", "DellscCLI"] \ No newline at end of file diff --git a/sunbeam-python/sunbeam/storage/backends/dellsc/backend.py b/sunbeam-python/sunbeam/storage/backends/dellsc/backend.py index 1f257b4a6..616885743 100644 --- a/sunbeam-python/sunbeam/storage/backends/dellsc/backend.py +++ b/sunbeam-python/sunbeam/storage/backends/dellsc/backend.py @@ -4,33 +4,14 @@ """Dell Storage Center storage backend implementation using base step classes.""" import logging -from typing import Any, Dict, Literal, Set +from typing import Annotated, Literal -import click - -try: - import yaml as _yaml # type: ignore - - yaml: Any = _yaml -except Exception: # yaml optional; handle gracefully at runtime - yaml = None - -# Import pydantic Field directly from pydantic import Field from rich.console import Console -from sunbeam.core.common import BaseStep -from sunbeam.core.deployment import Deployment -from sunbeam.core.juju import JujuHelper -from sunbeam.core.manifest import Manifest -from sunbeam.core.terraform import TerraformHelper +from sunbeam.core.manifest import StorageBackendConfig from sunbeam.storage.base import StorageBackendBase -from sunbeam.storage.models import StorageBackendConfig -from sunbeam.storage.steps import ( - BaseStorageBackendConfigUpdateStep, - BaseStorageBackendDeployStep, - BaseStorageBackendDestroyStep, -) +from sunbeam.storage.models import SecretDictField LOG = logging.getLogger(__name__) console = Console() @@ -43,355 +24,146 @@ class DellSCConfig(StorageBackendConfig): cinder-volume-dellsc charm as defined in charmcraft.yaml. """ - # Required fields (inherited from StorageBackendConfig) - # name: str (from base class) - # Mandatory connection parameters - san_ip: str = Field(..., description="Dell Storage Center management IP or hostname") - san_username: str = Field(..., description="SAN management username") - san_password: str = Field(..., description="SAN management password") - dell_sc_ssn: int = Field(default=64702, description="Storage Center System Serial Number") - protocol: Literal["fc", "iscsi"] = Field( - default="fc", description="Front-end protocol (fc or iscsi)" - ) + san_ip: Annotated[ + str, Field(description="Dell Storage Center management IP or hostname") + ] + san_username: Annotated[ + str, + Field(description="SAN management username"), + SecretDictField(field="primary-username"), + ] + san_password: Annotated[ + str, + Field(description="SAN management password"), + SecretDictField(field="primary-password"), + ] + dell_sc_ssn: Annotated[ + int | None, Field(description="Storage Center System Serial Number") + ] = None + protocol: Annotated[ + Literal["fc", "iscsi"] | None, + Field(description="Front-end protocol (fc or iscsi)"), + ] = None # Backend configuration - volume_backend_name: str = Field( - default="", description="Name that Cinder will report for this backend" - ) - backend_availability_zone: str = Field( - default="", description="Availability zone to associate with this backend" - ) + volume_backend_name: Annotated[ + str | None, Field(description="Name that Cinder will report for this backend") + ] = None + backend_availability_zone: Annotated[ + str | None, + Field(description="Availability zone to associate with this backend"), + ] = None # Dell Storage Center specific options - dell_sc_api_port: int = Field( - default=3033, description="Dell Storage Center API port" - ) - dell_sc_server_folder: str = Field( - default="openstack", description="Server folder name on Dell SC" - ) - dell_sc_volume_folder: str = Field( - default="openstack", description="Volume folder name on Dell SC" - ) - dell_server_os: str = Field( - default="Red Hat Linux 6.x", description="Server OS type for Dell SC" - ) - dell_sc_verify_cert: bool = Field( - default=False, description="Verify SSL certificate for Dell SC API" - ) + dell_sc_api_port: Annotated[ + int | None, Field(description="Dell Storage Center API port") + ] = None + dell_sc_server_folder: Annotated[ + str | None, Field(description="Server folder name on Dell SC") + ] = None + dell_sc_volume_folder: Annotated[ + str | None, Field(description="Volume folder name on Dell SC") + ] = None + dell_server_os: Annotated[ + str | None, Field(description="Server OS type for Dell SC") + ] = None + dell_sc_verify_cert: Annotated[ + bool | None, Field(description="Verify SSL certificate for Dell SC API") + ] = None # Provisioning options - san_thin_provision: bool = Field( - default=True, description="Enable thin provisioning" - ) + san_thin_provision: Annotated[ + bool | None, Field(description="Enable thin provisioning") + ] = None # Domain and network filtering - excluded_domain_ips: str = Field( - default="", description="Comma-separated list of excluded domain IPs" - ) - included_domain_ips: str = Field( - default="", description="Comma-separated list of included domain IPs" - ) + excluded_domain_ips: Annotated[ + str | None, Field(description="Comma-separated list of excluded domain IPs") + ] = None + included_domain_ips: Annotated[ + str | None, Field(description="Comma-separated list of included domain IPs") + ] = None # Dual DSM configuration - secondary_san_ip: str = Field( - default="", description="Secondary Dell Storage Center management IP" - ) - secondary_san_username: str = Field( - default="Admin", description="Secondary SAN management username" - ) - secondary_san_password: str = Field( - default="", description="Secondary SAN management password" - ) - secondary_sc_api_port: int = Field( - default=3033, description="Secondary Dell Storage Center API port" - ) + secondary_san_ip: Annotated[ + str | None, Field(description="Secondary Dell Storage Center management IP") + ] = None + secondary_san_username: Annotated[ + str | None, + Field(description="Secondary SAN management username"), + SecretDictField(field="secondary-username"), + ] = None + secondary_san_password: Annotated[ + str | None, + Field(description="Secondary SAN management password"), + SecretDictField(field="secondary-password"), + ] = None + secondary_sc_api_port: Annotated[ + int | None, Field(description="Secondary Dell Storage Center API port") + ] = None # API timeout configuration - dell_api_async_rest_timeout: int = Field( - default=15, description="Async REST API timeout in seconds" - ) - dell_api_sync_rest_timeout: int = Field( - default=30, description="Sync REST API timeout in seconds" - ) + dell_api_async_rest_timeout: Annotated[ + int | None, Field(description="Async REST API timeout in seconds") + ] = None + dell_api_sync_rest_timeout: Annotated[ + int | None, Field(description="Sync REST API timeout in seconds") + ] = None # SSH connection settings - ssh_conn_timeout: int = Field( - default=30, description="SSH connection timeout in seconds" - ) - ssh_max_pool_conn: int = Field( - default=5, description="Maximum SSH pool connections" - ) - ssh_min_pool_conn: int = Field( - default=1, description="Minimum SSH pool connections" - ) - - # Juju secrets for credentials (not charm config options) - san_credentials_secret: str = Field( - default="", description="Juju secret URI for SAN credentials" - ) - secondary_san_credentials_secret: str = Field( - default="", description="Juju secret URI for secondary SAN credentials" - ) + ssh_conn_timeout: Annotated[ + int | None, Field(description="SSH connection timeout in seconds") + ] = None + ssh_max_pool_conn: Annotated[ + int | None, Field(description="Maximum SSH pool connections") + ] = None + ssh_min_pool_conn: Annotated[ + int | None, Field(description="Minimum SSH pool connections") + ] = None class DellSCBackend(StorageBackendBase): """Dell Storage Center storage backend implementation.""" - name = "dellsc" + backend_type = "dellsc" display_name = "Dell Storage Center" - charm_name = "cinder-volume-dellsc" - - def __init__(self): - """Initialize Dell Storage Center backend.""" - super().__init__() - self.tfplan = "dellsc-backend-plan" - self.tfplan_dir = "deploy-dellsc-backend" - - charm_channel = ( - "latest/edge" # Use edge for development, change to stable for production - ) - charm_revision = 3 - charm_base = "ubuntu@24.04" - backend_endpoint = "cinder-volume" - units = 1 - additional_integrations = [] @property - def config_class(self) -> type[StorageBackendConfig]: - """Return the configuration class for Dell Storage Center backend.""" - return DellSCConfig - - def _get_credential_fields(self) -> Set[str]: - """Get set of credential field names that should be excluded from charm config. - - For Dell SC backend, we exclude all credential fields and secret URIs. - """ - return { - # meta - "name", - # primary array credentials - "san_username", - "san_password", - # secondary array credentials - "secondary_san_username", - "secondary_san_password", - # juju secret URIs - "san_credentials_secret", - "secondary_san_credentials_secret", - } - - def get_field_mapping(self) -> Dict[str, str]: - """Get mapping from config fields to charm config options. - - Uses base class automatic mapping and excludes credential fields. - """ - # Start from the base automatic mapping - mapping = super().get_field_mapping() - - # Exclude credential fields - exclude = self._get_credential_fields() - return {k: v for k, v in mapping.items() if k not in exclude} - - def get_terraform_variables( - self, backend_name: str, config: StorageBackendConfig, model: str - ) -> Dict[str, Any]: - """Generate Terraform variables for Dell Storage Center backend deployment.""" - # Map our configuration fields to the correct charm configuration option names - config_dict = config.model_dump() - field_mapping = self.get_field_mapping() - - # Filter config using base class method, excluding credential fields - charm_config = self._filter_config_for_charm( - config_dict, field_mapping, exclude_fields=self._get_credential_fields() - ) - - # Build Terraform variables to match the plan's expected format - tfvars = { - "machine_model": model, - "charm_dellsc_name": self.charm_name, - "charm_dellsc_base": self.charm_base, - "charm_dellsc_channel": self.charm_channel, - "charm_dellsc_endpoint": self.backend_endpoint, - "charm_dellsc_revision": self.charm_revision, - "dellsc_backends": { - backend_name: { - "charm_config": charm_config, - # Main array credentials (always required) - "san_username": config_dict.get("san_username", ""), - "san_password": config_dict.get("san_password", ""), - # Secondary array credentials (optional) - "secondary_san_username": config_dict.get("secondary_san_username", ""), - "secondary_san_password": config_dict.get("secondary_san_password", ""), - } - }, - } - - return tfvars - - def _get_default_config(self) -> DellSCConfig: - """Get a default configuration instance for comparison. - - This creates a config instance with all fields set to their Pydantic defaults, - allowing proper filtering of default values in _filter_config_for_charm(). - """ - # Create instance with minimal required fields, letting Pydantic set defaults - return DellSCConfig( - name="dummy", # Required field - san_ip="dummy", # Required field - san_username="dummy", # Required field - san_password="dummy", # Required field, noqa: S106 - # All other fields will use their Pydantic Field() defaults: - # dell_sc_ssn=64702, protocol="fc", volume_backend_name="", - # backend_availability_zone="", dell_sc_api_port=3033, etc. - ) - - def prompt_for_config(self, backend_name: str) -> DellSCConfig: - """Prompt user for Dell Storage Center-specific configuration.""" - return self._prompt_for_config(backend_name) - - def _prompt_for_config(self, backend_name: str) -> DellSCConfig: - """Prompt user for Dell Storage Center backend configuration.""" - console.print( - "\n[bold blue]Dell Storage Center Backend Configuration[/bold blue]" - ) - console.print("Please provide the required configuration options:") - - # Prompt for required fields - san_ip = click.prompt( - "Management IP/FQDN", type=str, value_proc=self._validate_ip_or_fqdn - ) - dell_sc_ssn = click.prompt( - "Storage Center System Serial Number", type=int, default=64702 - ) - protocol = click.prompt( - "Protocol", - type=click.Choice(["fc", "iscsi"], case_sensitive=False), - default="fc", - ) - - # Main array credentials (will be automatically converted to Juju secret) - console.print("\n[bold yellow]Array Credentials[/bold yellow]") - console.print( - "These credentials will be automatically stored in a Juju secret." - ) - san_username = click.prompt("SAN username", type=str, default="admin") - san_password = click.prompt("SAN password", type=str, hide_input=True) - - # Optional: prompt for volume backend name (defaults to backend name) - volume_backend_name = click.prompt( - "Volume backend name", type=str, default=backend_name, show_default=True - ) - - # Optional: Dual DSM configuration - secondary_san_username = "" - secondary_san_password = "" - secondary_san_ip = "" - - configure_dual_dsm = click.confirm( - "\nConfigure dual DSM (high availability)?", default=False - ) - if configure_dual_dsm: - console.print("\n[bold yellow]Secondary DSM Configuration[/bold yellow]") - secondary_san_ip = click.prompt( - "Secondary management IP/FQDN", type=str, value_proc=self._validate_ip_or_fqdn - ) - secondary_san_username = click.prompt( - "Secondary SAN username", type=str, default="Admin" - ) - secondary_san_password = click.prompt( - "Secondary SAN password", type=str, hide_input=True - ) - - return DellSCConfig( - name=backend_name, - san_ip=san_ip, - san_username=san_username, - san_password=san_password, - dell_sc_ssn=dell_sc_ssn, - protocol=protocol, - volume_backend_name=volume_backend_name, - secondary_san_ip=secondary_san_ip, - secondary_san_username=secondary_san_username, - secondary_san_password=secondary_san_password, - ) - - # Implementation of abstract methods from StorageBackendBase - def create_deploy_step( - self, - deployment: Deployment, - client, - tfhelper: TerraformHelper, - jhelper: JujuHelper, - manifest: Manifest, - backend_name: str, - backend_config: StorageBackendConfig, - model: str, - ) -> BaseStep: - """Create a deployment step for Dell Storage Center backend.""" - return DellSCDeployStep( - deployment, - client, - tfhelper, - jhelper, - manifest, - backend_name, - backend_config, - self, - model, - ) - - def create_destroy_step( - self, - deployment: Deployment, - client, - tfhelper: TerraformHelper, - jhelper: JujuHelper, - manifest: Manifest, - backend_name: str, - model: str, - ) -> BaseStep: - """Create a destruction step for Dell Storage Center backend.""" - return DellSCDestroyStep( - deployment, - client, - tfhelper, - jhelper, - manifest, - backend_name, - self, - model, - ) - - def create_update_config_step( - self, - deployment: Deployment, - backend_name: str, - config_updates: Dict[str, Any], - ) -> BaseStep: - """Create a configuration update step for Dell Storage Center backend.""" - return DellSCUpdateConfigStep( - deployment, - self, - backend_name, - config_updates, - ) + def charm_name(self) -> str: + """Return the charm name for this backend.""" + return "cinder-volume-dellsc" + @property + def charm_channel(self) -> str: + """Return the charm channel for this backend.""" + return "latest/edge" -# Dell Storage Center-specific step implementations using base step classes -class DellSCDeployStep(BaseStorageBackendDeployStep): - """Deploy Dell Storage Center storage backend using base step class.""" + @property + def charm_revision(self) -> str | None: + """Return the charm revision for this backend.""" + return None - def get_terraform_variables(self) -> Dict[str, Any]: - """Get Terraform variables for Dell Storage Center backend deployment.""" - return self.backend_instance.get_terraform_variables( - self.backend_name, self.backend_config, self.model - ) + @property + def charm_base(self) -> str: + """Return the charm base for this backend.""" + return "ubuntu@24.04" + @property + def backend_endpoint(self) -> str: + """Return the backend endpoint for this backend.""" + return "cinder-volume" -class DellSCDestroyStep(BaseStorageBackendDestroyStep): - """Destroy Dell Storage Center storage backend using base step class.""" + @property + def units(self) -> int: + """Return the number of units for this backend.""" + return 1 + @property + def additional_integrations(self) -> list[str]: + """Return a list of additional integrations for this backend.""" + return [] -class DellSCUpdateConfigStep(BaseStorageBackendConfigUpdateStep): - """Update Dell Storage Center storage backend configuration using base step class.""" + def config_type(self) -> type[StorageBackendConfig]: + """Return the configuration class for Dell Storage Center backend.""" + return DellSCConfig diff --git a/sunbeam-python/sunbeam/storage/backends/dellsc/cli.py b/sunbeam-python/sunbeam/storage/backends/dellsc/cli.py deleted file mode 100644 index ad8e94a16..000000000 --- a/sunbeam-python/sunbeam/storage/backends/dellsc/cli.py +++ /dev/null @@ -1,344 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -"""CLI functionality for Dell Storage Center storage backend. - -This module contains all CLI-related code moved from backend.py, -including command registration and helper functions. -""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any, Dict, Optional - -import click -import pydantic -from rich.console import Console - -try: - import yaml as _yaml # type: ignore - - yaml: Any = _yaml -except Exception: # yaml optional; handle gracefully at runtime - yaml = None - -from sunbeam.core.deployment import Deployment -from sunbeam.storage.backends.dellsc.backend import DellSCBackend -from sunbeam.storage.service import StorageBackendService - -console = Console() - - -class DellscCLI: - """CLI functionality for Dell Storage Center storage backend.""" - - def __init__(self, backend: DellSCBackend): - self.backend = backend - - def _load_config_file(self, path: Optional[Path]) -> Dict[str, Any]: - """Load YAML or JSON config file into a dictionary. - - YAML is preferred if PyYAML is available, otherwise JSON is used. - """ - if not path: - return {} - text = path.read_text() - if yaml is not None: - return dict(yaml.safe_load(text) or {}) - - return dict(json.loads(text)) - - def register_add_cli(self, add: click.Group) -> None: # noqa: C901 - """Register 'sunbeam storage add dellsc'. - - Includes typed options and a --config-file flag. - """ - - def _click_type_for(field_info) -> click.types.ParamType: - # Map pydantic field to Click type - ann = getattr(field_info, "annotation", None) - typ = None - if ann is not None: - typ = str(ann) - elif hasattr(field_info, "type_"): - typ = str(field_info.type_) - if typ and ("int" in typ): - return click.INT - if typ and ("float" in typ): - return click.FLOAT - if typ and ("bool" in typ): - return click.BOOL - return click.STRING - - def _build_params(required_all: bool) -> list: - params: list = [] - # name option (not required; prompt in interactive mode) - params.append( - click.Option(["--name"], type=str, required=False, help="Backend name") - ) - # config file (optional) - params.append( - click.Option( - ["--config-file"], - type=click.Path(exists=True, dir_okay=False, path_type=Path), - required=False, - help="YAML/JSON config file", - ) - ) - # Model-derived options - fields = getattr(self.backend.config_class, "model_fields", {}) - for fname, finfo in fields.items(): - if fname == "name": - continue - opt_name = "--" + fname.replace("_", "-") - click_type = _click_type_for(finfo) - # Determine requiredness for add (respect model) - # For interactive UX, keep CLI options optional; the model - # enforces requiredness. - is_required = False - # Help text - descr = None - if hasattr(finfo, "field_info") and hasattr( - finfo.field_info, "description" - ): - descr = finfo.field_info.description - elif hasattr(finfo, "description"): - descr = finfo.description - params.append( - click.Option( - [opt_name], type=click_type, required=is_required, help=descr - ) - ) - return params - - def _build_config_from_kwargs(kwargs: Dict[str, Any]) -> Dict[str, Any]: - # Extract name and field values from kwargs - # (Click converts dashes to underscores) - cfg: Dict[str, Any] = {} - for k, v in kwargs.items(): - if v is None: - continue - cfg[k] = v - return cfg - - def add_callback(**kwargs): - deployment: Deployment = click.get_current_context().obj - cfg_file = kwargs.pop("config_file", None) - file_cfg = self._load_config_file(cfg_file) - cli_cfg = _build_config_from_kwargs(kwargs) - # Determine if interactive: no config-file and no CLI options supplied - provided_cli_values = {k: v for k, v in cli_cfg.items() if v is not None} - interactive = not file_cfg and not provided_cli_values - - if interactive: - # Prompt for name and full config via helper - console.print( - f"[blue]Setting up {self.backend.display_name} backend[/blue]" - ) - backend_name = click.prompt("Backend name", type=str) - config_instance = self.backend.prompt_for_config(backend_name) - config_instance.name = backend_name - self.backend.add_backend( - deployment, backend_name, config_instance, console - ) - return - - # Non-interactive path: merge file and CLI, validate - merged = {**file_cfg, **provided_cli_values} - try: - config_instance = self.backend.config_class(**merged) - except pydantic.ValidationError as e: - console.print("[red]Configuration validation error:[/red]") - for error in e.errors(): - field_name = error.get("loc", ["unknown"])[0] - # Convert field name to CLI parameter format - cli_param = f"--{field_name.replace('_', '-')}" - console.print(f" {cli_param}: {error['msg']}") - raise click.Abort() - backend_name = merged.get("name") - if not backend_name: - raise click.BadParameter( - "--name is required when not running interactively" - ) - self.backend.add_backend(deployment, backend_name, config_instance, console) - - # Build command dynamically with parameters - params = _build_params(required_all=False) - help_text = ( - f"Add {self.backend.display_name} backend.\n\n" - "Behavior:\n" - "- If no options are provided, runs in interactive mode and prompts " - "for all required fields.\n" - "- If options and/or --config-file are provided, runs non-" - "interactively and validates against the model.\n" - "- In non-interactive mode, --name is required (or supplied via " - "--config-file).\n\n" - "Examples:\n" - " sunbeam storage add dellsc\n" - " sunbeam storage add dellsc --config-file dellsc.yaml\n" - " sunbeam storage add dellsc --name mydellsc --san-ip 10.0.0.10 " - "--protocol fc\n" - ) - cmd = click.Command( - name=self.backend.name, - params=params, - callback=add_callback, - help=help_text, - ) - add.add_command(cmd) - - def register_cli( # noqa: C901 - self, - remove: click.Group, - config_show: click.Group, - config_set: click.Group, - config_options: click.Group, - deployment: Deployment, - ) -> None: - """Register management commands for Dell Storage Center backend.""" - - @click.command(name=self.backend.name) - @click.argument("backend_name", type=str) - @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") - @click.pass_context - def remove_dellsc(ctx, backend_name: str, yes: bool): - service = self.backend._get_service(deployment) - if not service.backend_exists(backend_name, self.backend.name): - console.print(f"[red]Error: Backend '{backend_name}' not found[/red]") - raise click.Abort() - if not yes: - click.confirm( - f"Remove {self.backend.display_name} backend '{backend_name}'?", - abort=True, - ) - try: - self.backend.remove_backend(deployment, backend_name, console) - except Exception as e: - console.print(f"[red]Error removing backend: {e}[/red]") - raise click.Abort() - - remove.add_command(remove_dellsc) - - @click.command(name=self.backend.name) - @click.argument("backend_name", type=str) - @click.pass_context - def config_show_dellsc(ctx, backend_name: str): - service = self.backend._get_service(deployment) - config = service.get_backend_config(backend_name, self.backend.name) - self.backend.display_config_table(backend_name, config) - - config_show.add_command(config_show_dellsc) - - # Build typed options for config set (only provided options are updated) - def _build_set_params() -> list: - params = [] - # Add config-file option first - params.append( - click.Option( - ["--config-file"], - type=click.Path( - exists=True, dir_okay=False, readable=True, path_type=Path - ), - required=False, - help="YAML/JSON config file with updates", - ) - ) - fields = getattr(self.backend.config_class, "model_fields", {}) - for fname, finfo in fields.items(): - if fname == "name": - continue - opt = "--" + fname.replace("_", "-") - # For updates, make everything optional with default None - # so we can detect presence - click_type: click.ParamType = click.STRING - ann = getattr(finfo, "annotation", None) - if ann is not None and ("bool" in str(ann)): - click_type = click.BOOL - elif ann is not None and ("int" in str(ann)): - click_type = click.INT - elif ann is not None and ("float" in str(ann)): - click_type = click.FLOAT - descr = None - if hasattr(finfo, "field_info") and hasattr( - finfo.field_info, "description" - ): - descr = finfo.field_info.description - elif hasattr(finfo, "description"): - descr = finfo.description - params.append( - click.Option( - [opt], type=click_type, required=False, default=None, help=descr - ) - ) - return params - - def set_callback(backend_name: str, **kwargs): - cfg_file = kwargs.pop("config_file", None) - file_cfg = {} - if cfg_file: - file_cfg = self._load_config_file(cfg_file) - # Only include keys that were explicitly provided (value not None) - updates = {k: v for k, v in kwargs.items() if v is not None} - updates = {**file_cfg, **updates} - try: - # Get current configuration and merge with updates for validation - service = StorageBackendService(deployment) - current_config = service.get_backend_config( - backend_name, self.backend.name - ) - - # Create a merged config for validation - # Current config comes from charm (kebab-case keys), - # convert to snake_case - merged_config = {} - for key, value in current_config.items(): - snake_key = key.replace("-", "_") - merged_config[snake_key] = value - - # Updates from CLI are already in snake_case - # (Click converts --protocol to protocol) - merged_config.update(updates) - merged_config["name"] = backend_name # Ensure name is set - _ = self.backend.config_class(**merged_config) - except pydantic.ValidationError as e: - console.print("[red]Configuration validation error:[/red]") - for error in e.errors(): - field_name = error["loc"][0] if error.get("loc") else "unknown" - # Convert field name to CLI parameter format - cli_param = f"--{str(field_name).replace('_', '-')}" - console.print(f" {cli_param}: {error['msg']}") - raise click.ClickException( - "Configuration update failed due to validation error" - ) - try: - self.backend.update_backend_config(deployment, backend_name, updates) - console.print( - ( - f"[green]Configuration updated for {self.backend.display_name} " - f"backend '{backend_name}'[/green]" - ) - ) - except Exception as e: - console.print(f"[red]Failed to update configuration: {e}[/red]") - raise click.ClickException(f"Configuration update failed: {e}") - - set_params = _build_set_params() - # Add backend_name as a required argument - set_params.insert(0, click.Argument(["backend_name"], type=str, required=True)) - set_cmd = click.Command( - name=self.backend.name, - params=set_params, - callback=set_callback, - help="Set configuration options", - ) - config_set.add_command(set_cmd) - - @click.command(name=self.backend.name) - @click.argument("backend_name", type=str, required=False) - @click.pass_context - def config_options_dellsc(ctx, backend_name: str | None = None): - self.backend.display_config_options() - - config_options.add_command(config_options_dellsc) diff --git a/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/main.tf b/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/main.tf deleted file mode 100644 index 35eb78acd..000000000 --- a/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/main.tf +++ /dev/null @@ -1,140 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -terraform { - required_providers { - juju = { - source = "juju/juju" - version = "= 0.20.0" - } - } -} - -provider "juju" {} - -data "juju_model" "machine_model" { - name = var.machine_model -} - -# Create Juju secrets for Dell Storage Center backend credentials -# Main array credentials (required for all backends) -resource "juju_secret" "dellsc_san_credentials" { - for_each = { - for backend_name, backend_config in var.dellsc_backends : - backend_name => backend_config - if backend_config.san_username != "" && backend_config.san_password != "" - } - - model = data.juju_model.machine_model.name - name = "${each.key}-san-credentials" - value = { - username = each.value.san_username - password = each.value.san_password - } -} - -# Secondary array credentials (optional, for dual DSM) -resource "juju_secret" "dellsc_secondary_san_credentials" { - for_each = { - for backend_name, backend_config in var.dellsc_backends : - backend_name => backend_config - if backend_config.secondary_san_username != "" && backend_config.secondary_san_password != "" - } - - model = data.juju_model.machine_model.name - name = "${each.key}-secondary-san-credentials" - value = { - username = each.value.secondary_san_username - password = each.value.secondary_san_password - } -} - -# Grant access to secrets for Dell Storage Center backend applications -resource "juju_access_secret" "dellsc_san_credentials_access" { - for_each = { - for backend_name, backend_config in var.dellsc_backends : - backend_name => backend_config - if backend_config.san_username != "" && backend_config.san_password != "" - } - - model = data.juju_model.machine_model.name - secret_id = juju_secret.dellsc_san_credentials[each.key].secret_id - applications = [each.key] - - # Ensure proper dependency ordering to avoid provider bugs - depends_on = [juju_application.dellsc_backends] - - lifecycle { - # Prevent destruction when applications list becomes empty - prevent_destroy = false - # Create before destroy to avoid empty applications list state - create_before_destroy = true - } -} - -resource "juju_access_secret" "dellsc_secondary_san_credentials_access" { - for_each = { - for backend_name, backend_config in var.dellsc_backends : - backend_name => backend_config - if backend_config.secondary_san_username != "" && backend_config.secondary_san_password != "" - } - - model = data.juju_model.machine_model.name - secret_id = juju_secret.dellsc_secondary_san_credentials[each.key].secret_id - applications = [each.key] - - # Ensure proper dependency ordering to avoid provider bugs - depends_on = [juju_application.dellsc_backends] - - lifecycle { - # Prevent destruction when applications list becomes empty - prevent_destroy = false - # Create before destroy to avoid empty applications list state - create_before_destroy = true - } -} - -# Deploy Dell Storage Center backend charms -resource "juju_application" "dellsc_backends" { - for_each = var.dellsc_backends - - name = each.key - model = data.juju_model.machine_model.name - units = 1 - - charm { - name = var.charm_dellsc_name - channel = var.charm_dellsc_channel - revision = var.charm_dellsc_revision - base = var.charm_dellsc_base - } - - config = merge({ - volume-backend-name = each.key - }, each.value.charm_config, { - # Main array credentials - always required - san-credentials-secret = contains(keys(juju_secret.dellsc_san_credentials), each.key) ? juju_secret.dellsc_san_credentials[each.key].secret_id : "" - - # Secondary array credentials - only if dual DSM is configured - secondary-san-credentials-secret = contains(keys(juju_secret.dellsc_secondary_san_credentials), each.key) ? juju_secret.dellsc_secondary_san_credentials[each.key].secret_id : "" - }) - - endpoint_bindings = var.endpoint_bindings -} - -# Integrate Dell Storage Center backends with main cinder-volume -resource "juju_integration" "dellsc_to_cinder_volume" { - for_each = var.dellsc_backends - - model = var.machine_model - - application { - name = juju_application.dellsc_backends[each.key].name - endpoint = var.charm_dellsc_endpoint - } - - application { - name = "cinder-volume" - endpoint = var.charm_dellsc_endpoint - } -} diff --git a/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/outputs.tf b/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/outputs.tf deleted file mode 100644 index 295810f44..000000000 --- a/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/outputs.tf +++ /dev/null @@ -1,13 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -output "dellsc_backend_applications" { - description = "Map of deployed Dell Storage Center backend applications" - value = { - for name, app in juju_application.dellsc_backends : name => { - name = app.name - model = app.model - units = app.units - } - } -} diff --git a/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/test-variables.tfvars b/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/test-variables.tfvars deleted file mode 100644 index 7464d4c48..000000000 --- a/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/test-variables.tfvars +++ /dev/null @@ -1,41 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -# Test variables for Dell Storage Center backend deployment - -machine_model = "openstack" - -charm_dellsc_name = "cinder-volume-dellsc" -charm_dellsc_base = "ubuntu@24.04" -charm_dellsc_channel = "latest/edge" -charm_dellsc_endpoint = "cinder-volume" -charm_dellsc_revision = 1 - -dellsc_backends = { - "dellsc-test" = { - charm_config = { - san-ip = "192.168.1.100" - dell-sc-ssn = "64702" - protocol = "fc" - dell-sc-api-port = "3033" - dell-sc-server-folder = "openstack" - dell-sc-volume-folder = "openstack" - dell-server-os = "Red Hat Linux 6.x" - dell-sc-verify-cert = "false" - san-thin-provision = "true" - dell-api-async-rest-timeout = "15" - dell-api-sync-rest-timeout = "30" - ssh-conn-timeout = "30" - ssh-max-pool-conn = "5" - ssh-min-pool-conn = "1" - } - - # Main array credentials - san_username = "admin" - san_password = "password123" - - # Secondary array credentials (optional, for dual DSM) - secondary_san_username = "" - secondary_san_password = "" - } -} diff --git a/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/variables.tf b/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/variables.tf deleted file mode 100644 index 1ec1ebcd0..000000000 --- a/sunbeam-python/sunbeam/storage/backends/dellsc/deploy-dellsc-backend/variables.tf +++ /dev/null @@ -1,65 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -variable "machine_model" { - description = "Name of the machine model to deploy to" - type = string -} - -variable "charm_dellsc_name" { - description = "Name of the Dell Storage Center charm" - type = string - default = "cinder-volume-dellsc" -} - -variable "charm_dellsc_base" { - description = "Base for the Dell Storage Center charm" - type = string - default = "ubuntu@24.04" -} - -variable "charm_dellsc_channel" { - description = "Operator channel for Dell Storage Center backend deployment" - type = string - default = "latest/edge" -} - -variable "charm_dellsc_endpoint" { - description = "Endpoint name for Dell Storage Center backend integration" - type = string - default = "cinder-volume" -} - -variable "charm_dellsc_revision" { - description = "Operator channel revision for Dell Storage Center backend deployment" - type = number - default = null -} - -variable "dellsc_backends" { - description = "Map of Dell Storage Center backend configurations" - type = map(object({ - charm_config = map(string) - - # Main array credentials (required) - san_username = string - san_password = string - - # Secondary array credentials (optional, for dual DSM) - secondary_san_username = optional(string, "") - secondary_san_password = optional(string, "") - })) - default = {} -} - -variable "machine_ids" { - description = "List of machine ids to include" - type = list(string) - default = [] -} - -variable "endpoint_bindings" { - description = "Endpoint bindings for the applications" - type = set(map(string)) - default = null -} diff --git a/sunbeam-python/sunbeam/storage/backends/hitachi/__init__.py b/sunbeam-python/sunbeam/storage/backends/hitachi/__init__.py new file mode 100644 index 000000000..c661a9f84 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/hitachi/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Hitachi backend for Sunbeam storage.""" diff --git a/sunbeam-python/sunbeam/storage/backends/hitachi/backend.py b/sunbeam-python/sunbeam/storage/backends/hitachi/backend.py index ef3875988..56ba12462 100644 --- a/sunbeam-python/sunbeam/storage/backends/hitachi/backend.py +++ b/sunbeam-python/sunbeam/storage/backends/hitachi/backend.py @@ -4,33 +4,14 @@ """Hitachi VSP storage backend implementation using base step classes.""" import logging -from typing import Any, Dict, Literal, Set +from typing import Annotated, Literal -import click - -try: - import yaml as _yaml # type: ignore - - yaml: Any = _yaml -except Exception: # yaml optional; handle gracefully at runtime - yaml = None - -# Import pydantic Field directly from pydantic import Field from rich.console import Console -from sunbeam.core.common import BaseStep -from sunbeam.core.deployment import Deployment -from sunbeam.core.juju import JujuHelper -from sunbeam.core.manifest import Manifest -from sunbeam.core.terraform import TerraformHelper +from sunbeam.core.manifest import StorageBackendConfig from sunbeam.storage.base import StorageBackendBase -from sunbeam.storage.models import StorageBackendConfig -from sunbeam.storage.steps import ( - BaseStorageBackendConfigUpdateStep, - BaseStorageBackendDeployStep, - BaseStorageBackendDestroyStep, -) +from sunbeam.storage.models import SecretDictField LOG = logging.getLogger(__name__) console = Console() @@ -43,566 +24,295 @@ class HitachiConfig(StorageBackendConfig): cinder-volume-hitachi charm as defined in charmcraft.yaml. """ - # Required fields (inherited from StorageBackendConfig) - # name: str (from base class) - # Mandatory connection parameters - hitachi_storage_id: str = Field( - ..., description="Storage system product number/serial" - ) - hitachi_pools: str = Field( - ..., description="Comma-separated list of DP pool names/IDs" - ) - san_ip: str = Field(..., description="Hitachi VSP management IP or hostname") - san_username: str = Field(..., description="SAN management username") - san_password: str = Field(..., description="SAN management password") - protocol: Literal["FC", "iSCSI"] = Field( - ..., description="Front-end protocol (FC or iSCSI)" - ) + hitachi_storage_id: Annotated[ + str, Field(description="Storage system product number/serial") + ] + hitachi_pools: Annotated[ + str, Field(description="Comma-separated list of DP pool names/IDs") + ] + san_ip: Annotated[str, Field(description="Hitachi VSP management IP or hostname")] + san_username: Annotated[ + str, + Field(description="SAN management username"), + SecretDictField(field="san-username"), + ] + san_password: Annotated[ + str, + Field(description="SAN management password"), + SecretDictField(field="san-password"), + ] + protocol: Annotated[ + Literal["FC", "iSCSI"], Field(description="Front-end protocol (FC or iSCSI)") + ] # Backend configuration - volume_backend_name: str = Field( - default="", description="Name that Cinder will report for this backend" - ) - backend_availability_zone: str = Field( - default="", description="Availability zone to associate with this backend" - ) + volume_backend_name: Annotated[ + str | None, Field(description="Name that Cinder will report for this backend") + ] = None + backend_availability_zone: Annotated[ + str | None, + Field(description="Availability zone to associate with this backend"), + ] = None # Optional host-group / zoning controls - hitachi_target_ports: str = Field( - default="", description="Comma-separated front-end port labels" - ) - hitachi_compute_target_ports: str = Field( - default="", description="Comma-separated compute-node port IDs" - ) - hitachi_ldev_range: str = Field( - default="", description="LDEV range usable by the driver" - ) - hitachi_zoning_request: bool = Field( - default=False, description="Request FC zone-manager to create zoning" - ) + hitachi_target_ports: Annotated[ + str | None, Field(description="Comma-separated front-end port labels") + ] = None + hitachi_compute_target_ports: Annotated[ + str | None, Field(description="Comma-separated compute-node port IDs") + ] = None + hitachi_ldev_range: Annotated[ + str | None, Field(description="LDEV range usable by the driver") + ] = None + hitachi_zoning_request: Annotated[ + bool | None, Field(description="Request FC zone-manager to create zoning") + ] = None # Copy & replication tuning - hitachi_copy_speed: int = Field( - default=3, description="Copy bandwidth throttle (1-15)" - ) - hitachi_copy_check_interval: int = Field( - default=3, description="Seconds between sync copy-status polls" - ) - hitachi_async_copy_check_interval: int = Field( - default=10, description="Seconds between async copy-status polls" - ) + hitachi_copy_speed: Annotated[ + int | None, Field(description="Copy bandwidth throttle (1-15)") + ] = None + hitachi_copy_check_interval: Annotated[ + int | None, Field(description="Seconds between sync copy-status polls") + ] = None + hitachi_async_copy_check_interval: Annotated[ + int | None, Field(description="Seconds between async copy-status polls") + ] = None # iSCSI authentication - use_chap_auth: bool = Field( - default=False, description="Use CHAP authentication for iSCSI" - ) + use_chap_auth: Annotated[ + bool | None, Field(description="Use CHAP authentication for iSCSI") + ] = None # Array ranges and controls - hitachi_discard_zero_page: bool = Field( - default=True, description="Enable zero-page reclamation in DP-VOLs" - ) - hitachi_exec_retry_interval: int = Field( - default=5, description="Seconds to wait before retrying REST API call" - ) - hitachi_extend_timeout: int = Field( - default=600, description="Max seconds to wait for volume extension" - ) - hitachi_group_create: bool = Field( - default=False, description="Automatically create host groups or iSCSI targets" - ) - hitachi_group_delete: bool = Field( - default=False, description="Automatically delete unused host groups" - ) - hitachi_group_name_format: str = Field( - default="", description="Python format string for naming host groups" - ) - hitachi_host_mode_options: str = Field( - default="", description="Comma-separated host mode options" - ) - hitachi_lock_timeout: int = Field( - default=7200, description="Max seconds for array login/unlock operations" - ) - hitachi_lun_retry_interval: int = Field( - default=1, description="Seconds before retrying LUN mapping" - ) - hitachi_lun_timeout: int = Field( - default=50, description="Max seconds to wait for LUN mapping" - ) - hitachi_port_scheduler: bool = Field( - default=False, description="Enable round-robin WWN registration" - ) + hitachi_discard_zero_page: Annotated[ + bool | None, Field(description="Enable zero-page reclamation in DP-VOLs") + ] = None + hitachi_exec_retry_interval: Annotated[ + int | None, Field(description="Seconds to wait before retrying REST API call") + ] = None + hitachi_extend_timeout: Annotated[ + int | None, Field(description="Max seconds to wait for volume extension") + ] = None + hitachi_group_create: Annotated[ + bool | None, + Field(description="Automatically create host groups or iSCSI targets"), + ] = None + hitachi_group_delete: Annotated[ + bool | None, Field(description="Automatically delete unused host groups") + ] = None + hitachi_group_name_format: Annotated[ + str | None, Field(description="Python format string for naming host groups") + ] = None + hitachi_host_mode_options: Annotated[ + str | None, Field(description="Comma-separated host mode options") + ] = None + hitachi_lock_timeout: Annotated[ + int | None, Field(description="Max seconds for array login/unlock operations") + ] = None + hitachi_lun_retry_interval: Annotated[ + int | None, Field(description="Seconds before retrying LUN mapping") + ] = None + hitachi_lun_timeout: Annotated[ + int | None, Field(description="Max seconds to wait for LUN mapping") + ] = None + hitachi_port_scheduler: Annotated[ + bool | None, Field(description="Enable round-robin WWN registration") + ] = None # Mirror/replication settings - hitachi_mirror_compute_target_ports: str = Field( - default="", description="Compute-node port names for GAD" - ) - hitachi_mirror_ldev_range: str = Field( - default="", description="LDEV range for secondary storage" - ) - hitachi_mirror_pair_target_number: int = Field( - default=0, description="Host group number for GAD on secondary" - ) - hitachi_mirror_pool: str = Field( - default="", description="DP pool name/ID on secondary storage" - ) - hitachi_mirror_rest_api_ip: str = Field( - default="", description="REST API IP on secondary storage" - ) - hitachi_mirror_rest_api_port: int = Field( - default=443, description="REST API port on secondary storage" - ) - hitachi_mirror_rest_pair_target_ports: str = Field( - default="", description="Pair-target port names for GAD" - ) - hitachi_mirror_snap_pool: str = Field( - default="", description="Snapshot pool on secondary storage" - ) - hitachi_mirror_ssl_cert_path: str = Field( - default="", description="CA_BUNDLE for secondary REST endpoint" - ) - hitachi_mirror_ssl_cert_verify: bool = Field( - default=False, description="Validate SSL cert of secondary REST" - ) - hitachi_mirror_storage_id: str = Field( - default="", description="Product number of secondary storage" - ) - hitachi_mirror_target_ports: str = Field( - default="", description="Controller node port IDs for GAD" - ) - hitachi_mirror_use_chap_auth: bool = Field( - default=False, description="Use CHAP auth for GAD on secondary" - ) + hitachi_mirror_compute_target_ports: Annotated[ + str | None, Field(description="Compute-node port names for GAD") + ] = None + hitachi_mirror_ldev_range: Annotated[ + str | None, Field(description="LDEV range for secondary storage") + ] = None + hitachi_mirror_pair_target_number: Annotated[ + int | None, Field(description="Host group number for GAD on secondary") + ] = None + hitachi_mirror_pool: Annotated[ + str | None, Field(description="DP pool name/ID on secondary storage") + ] = None + hitachi_mirror_rest_api_ip: Annotated[ + str | None, Field(description="REST API IP on secondary storage") + ] = None + hitachi_mirror_rest_api_port: Annotated[ + int | None, Field(description="REST API port on secondary storage") + ] = None + hitachi_mirror_rest_pair_target_ports: Annotated[ + str | None, Field(description="Pair-target port names for GAD") + ] = None + hitachi_mirror_snap_pool: Annotated[ + str | None, Field(description="Snapshot pool on secondary storage") + ] = None + hitachi_mirror_ssl_cert_path: Annotated[ + str | None, Field(description="CA_BUNDLE for secondary REST endpoint") + ] = None + hitachi_mirror_ssl_cert_verify: Annotated[ + bool | None, Field(description="Validate SSL cert of secondary REST") + ] = None + hitachi_mirror_storage_id: Annotated[ + str | None, Field(description="Product number of secondary storage") + ] = None + hitachi_mirror_target_ports: Annotated[ + str | None, Field(description="Controller node port IDs for GAD") + ] = None + hitachi_mirror_use_chap_auth: Annotated[ + bool | None, Field(description="Use CHAP auth for GAD on secondary") + ] = None # Replication settings - hitachi_pair_target_number: int = Field( - default=0, description="Host group number for primary replication" - ) - hitachi_path_group_id: int = Field( - default=0, description="Path group ID for remote replication" - ) - hitachi_quorum_disk_id: int = Field( - default=0, description="Quorum disk ID for Global-Active Device" - ) - hitachi_replication_copy_speed: int = Field( - default=3, description="Copy speed for remote replication" - ) - hitachi_replication_number: int = Field( - default=0, description="Instance number for REST API on replication" - ) - hitachi_replication_status_check_long_interval: int = Field( - default=600, description="Poll interval after initial check" - ) - hitachi_replication_status_check_short_interval: int = Field( - default=5, description="Initial poll interval" - ) - hitachi_replication_status_check_timeout: int = Field( - default=86400, description="Max seconds for status change" - ) + hitachi_pair_target_number: Annotated[ + int | None, Field(description="Host group number for primary replication") + ] = None + hitachi_path_group_id: Annotated[ + int | None, Field(description="Path group ID for remote replication") + ] = None + hitachi_quorum_disk_id: Annotated[ + int | None, Field(description="Quorum disk ID for Global-Active Device") + ] = None + hitachi_replication_copy_speed: Annotated[ + int | None, Field(description="Copy speed for remote replication") + ] = None + hitachi_replication_number: Annotated[ + int | None, Field(description="Instance number for REST API on replication") + ] = None + hitachi_replication_status_check_long_interval: Annotated[ + int | None, Field(description="Poll interval after initial check") + ] = None + hitachi_replication_status_check_short_interval: Annotated[ + int | None, Field(description="Initial poll interval") + ] = None + hitachi_replication_status_check_timeout: Annotated[ + int | None, Field(description="Max seconds for status change") + ] = None # REST API settings - hitachi_rest_another_ldev_mapped_retry_timeout: int = Field( - default=600, description="Retry seconds when LDEV allocation fails" - ) - hitachi_rest_connect_timeout: int = Field( - default=30, description="Max seconds to establish REST connection" - ) - hitachi_rest_disable_io_wait: bool = Field( - default=True, description="Detach volumes without waiting for I/O drain" - ) - hitachi_rest_get_api_response_timeout: int = Field( - default=1800, description="Max seconds for sync REST GET" - ) - hitachi_rest_job_api_response_timeout: int = Field( - default=1800, description="Max seconds for async REST PUT/DELETE" - ) - hitachi_rest_keep_session_loop_interval: int = Field( - default=180, description="Seconds between keep-alive loops" - ) - hitachi_rest_pair_target_ports: str = Field( - default="", description="Pair-target port names for REST operations" - ) - hitachi_rest_server_busy_timeout: int = Field( - default=7200, description="Max seconds when REST API returns busy" - ) - hitachi_rest_tcp_keepalive: bool = Field( - default=True, description="Enable TCP keepalive for REST connections" - ) - hitachi_rest_tcp_keepcnt: int = Field( - default=4, description="Number of TCP keepalive probes" - ) - hitachi_rest_tcp_keepidle: int = Field( - default=60, description="Seconds before sending first TCP keepalive" - ) - hitachi_rest_tcp_keepintvl: int = Field( - default=15, description="Seconds between TCP keepalive probes" - ) - hitachi_rest_timeout: int = Field( - default=30, description="Max seconds for each REST API call" - ) - hitachi_restore_timeout: int = Field( - default=86400, description="Max seconds to wait for restore operation" - ) + hitachi_rest_another_ldev_mapped_retry_timeout: Annotated[ + int | None, Field(description="Retry seconds when LDEV allocation fails") + ] = None + hitachi_rest_connect_timeout: Annotated[ + int | None, Field(description="Max seconds to establish REST connection") + ] = None + hitachi_rest_disable_io_wait: Annotated[ + bool | None, Field(description="Detach volumes without waiting for I/O drain") + ] = None + hitachi_rest_get_api_response_timeout: Annotated[ + int | None, Field(description="Max seconds for sync REST GET") + ] = None + hitachi_rest_job_api_response_timeout: Annotated[ + int | None, Field(description="Max seconds for async REST PUT/DELETE") + ] = None + hitachi_rest_keep_session_loop_interval: Annotated[ + int | None, Field(description="Seconds between keep-alive loops") + ] = None + hitachi_rest_pair_target_ports: Annotated[ + str | None, Field(description="Pair-target port names for REST operations") + ] = None + hitachi_rest_server_busy_timeout: Annotated[ + int | None, Field(description="Max seconds when REST API returns busy") + ] = None + hitachi_rest_tcp_keepalive: Annotated[ + bool | None, Field(description="Enable TCP keepalive for REST connections") + ] = None + hitachi_rest_tcp_keepcnt: Annotated[ + int | None, Field(description="Number of TCP keepalive probes") + ] = None + hitachi_rest_tcp_keepidle: Annotated[ + int | None, Field(description="Seconds before sending first TCP keepalive") + ] = None + hitachi_rest_tcp_keepintvl: Annotated[ + int | None, Field(description="Seconds between TCP keepalive probes") + ] = None + hitachi_rest_timeout: Annotated[ + int | None, Field(description="Max seconds for each REST API call") + ] = None + hitachi_restore_timeout: Annotated[ + int | None, Field(description="Max seconds to wait for restore operation") + ] = None # Snapshot settings - hitachi_snap_pool: str = Field(default="", description="Pool name/ID for snapshots") - hitachi_state_transition_timeout: int = Field( - default=900, description="Max seconds for volume state transition" - ) - - # Juju secrets for credentials (not charm config options) - san_credentials_secret: str = Field( - default="", description="Juju secret URI for SAN credentials" - ) - chap_credentials_secret: str = Field( - default="", description="Juju secret URI for CHAP credentials" - ) - hitachi_mirror_chap_credentials_secret: str = Field( - default="", description="Juju secret URI for mirror CHAP credentials" - ) - hitachi_mirror_rest_credentials_secret: str = Field( - default="", description="Juju secret URI for mirror REST credentials" - ) - - chap_username: str = Field( - default="", description="CHAP username for secret creation" - ) - chap_password: str = Field( - default="", description="CHAP password for secret creation" - ) - hitachi_mirror_chap_username: str = Field( - default="", description="Mirror CHAP username for secret creation" - ) - hitachi_mirror_chap_password: str = Field( - default="", description="Mirror CHAP password for secret creation" - ) - hitachi_mirror_rest_username: str = Field( - default="", description="Mirror REST username for secret creation" - ) - hitachi_mirror_rest_password: str = Field( - default="", description="Mirror REST password for secret creation" - ) + hitachi_snap_pool: Annotated[ + str | None, Field(description="Pool name/ID for snapshots") + ] = None + hitachi_state_transition_timeout: Annotated[ + int | None, Field(description="Max seconds for volume state transition") + ] = None + + chap_username: Annotated[ + str | None, + Field(description="CHAP username for secret creation"), + SecretDictField(field="chap-username"), + ] = None + chap_password: Annotated[ + str | None, + Field(description="CHAP password for secret creation"), + SecretDictField(field="chap-password"), + ] = None + hitachi_mirror_chap_username: Annotated[ + str | None, + Field(description="Mirror CHAP username for secret creation"), + SecretDictField(field="mirror-chap-username"), + ] = None + hitachi_mirror_chap_password: Annotated[ + str | None, + Field(description="Mirror CHAP password for secret creation"), + SecretDictField(field="mirror-chap-password"), + ] = None + hitachi_mirror_rest_username: Annotated[ + str | None, + Field(description="Mirror REST username for secret creation"), + SecretDictField(field="mirror-rest-username"), + ] = None + hitachi_mirror_rest_password: Annotated[ + str | None, + Field(description="Mirror REST password for secret creation"), + SecretDictField(field="mirror-rest-password"), + ] = None class HitachiBackend(StorageBackendBase): """Hitachi storage backend implementation.""" - name = "hitachi" + backend_type = "hitachi" display_name = "Hitachi VSP Storage" - charm_name = "cinder-volume-hitachi" - - def __init__(self): - """Initialize Hitachi backend.""" - super().__init__() - self.tfplan = "hitachi-backend-plan" - self.tfplan_dir = "deploy-hitachi-backend" - - charm_channel = ( - "latest/edge" # Use edge for development, change to stable for production - ) - charm_revision = 2 - charm_base = "ubuntu@24.04" # Updated to match uploaded charm base - backend_endpoint = "cinder-volume" - units = 1 - additional_integrations = [] @property - def config_class(self) -> type[StorageBackendConfig]: - """Return the configuration class for Hitachi backend.""" - return HitachiConfig - - def _get_credential_fields(self) -> Set[str]: - """Get set of credential field names that should be excluded from charm config. - - For Hitachi backend, we exclude all credential fields and secret URIs. - """ - return { - # meta - "name", - # primary array credentials - "san_username", - "san_password", - # CHAP credentials - "chap_username", - "chap_password", - # mirror CHAP credentials - "hitachi_mirror_chap_username", - "hitachi_mirror_chap_password", - # mirror REST credentials - "hitachi_mirror_rest_username", - "hitachi_mirror_rest_password", - # juju secret URIs - "san_credentials_secret", - "chap_credentials_secret", - "hitachi_mirror_chap_credentials_secret", - "hitachi_mirror_rest_credentials_secret", - } - - def get_field_mapping(self) -> Dict[str, str]: - """Get mapping from config fields to charm config options. - - Uses base class automatic mapping and excludes credential fields. - """ - # Start from the base automatic mapping - mapping = super().get_field_mapping() - - # Exclude credential fields - exclude = self._get_credential_fields() - return {k: v for k, v in mapping.items() if k not in exclude} - - # CLI registration uses base class implementation - - def get_terraform_variables( - self, backend_name: str, config: StorageBackendConfig, model: str - ) -> Dict[str, Any]: - """Generate Terraform variables for Hitachi backend deployment.""" - # Map our configuration fields to the correct charm configuration option names - config_dict = config.model_dump() - field_mapping = self.get_field_mapping() - - # Filter config using base class method, excluding credential fields - charm_config = self._filter_config_for_charm( - config_dict, field_mapping, exclude_fields=self._get_credential_fields() - ) - - # Build Terraform variables to match the plan's expected format - tfvars = { - "machine_model": model, - "charm_hitachi_name": self.charm_name, - "charm_hitachi_base": self.charm_base, - "charm_hitachi_channel": self.charm_channel, - "charm_hitachi_endpoint": self.backend_endpoint, - "charm_hitachi_revision": self.charm_revision, - "hitachi_backends": { - backend_name: { - "charm_config": charm_config, - # Main array credentials (always required) - "san_username": config_dict.get("san_username", ""), - "san_password": config_dict.get("san_password", ""), - # CHAP credentials (optional) - "use_chap_auth": config_dict.get("use_chap_auth", False), - "chap_username": config_dict.get("chap_username", ""), - "chap_password": config_dict.get("chap_password", ""), - # Mirror CHAP credentials (optional) - "hitachi_mirror_chap_username": config_dict.get( - "hitachi_mirror_chap_username", "" - ), - "hitachi_mirror_chap_password": config_dict.get( - "hitachi_mirror_chap_password", "" - ), - # Mirror REST API credentials (optional) - "hitachi_mirror_rest_username": config_dict.get( - "hitachi_mirror_rest_username", "" - ), - "hitachi_mirror_rest_password": config_dict.get( - "hitachi_mirror_rest_password", "" - ), - } - }, - } - - return tfvars - - def _get_default_config(self) -> HitachiConfig: - """Get a default configuration instance for comparison.""" - return HitachiConfig( - name="dummy", - hitachi_storage_id="dummy", - hitachi_pools="dummy", - san_ip="dummy", - san_username="dummy", - san_password="dummy", # noqa: S106 - protocol="FC", - ) - - def prompt_for_config(self, backend_name: str) -> HitachiConfig: - """Prompt user for Hitachi-specific configuration.""" - return self._prompt_for_config(backend_name) - - # IP/FQDN validation uses base class implementation - - def _prompt_for_config(self, backend_name: str) -> HitachiConfig: - """Prompt user for Hitachi backend configuration.""" - console.print( - "\n[bold blue]Hitachi VSP Storage Backend Configuration[/bold blue]" - ) - console.print("Please provide the required configuration options:") - - # Prompt for required fields - hitachi_storage_id = click.prompt("Array serial number", type=str) - hitachi_pools = click.prompt("Storage pools (comma separated)", type=str) - protocol = click.prompt( - "Protocol", - type=click.Choice(["FC", "iSCSI"], case_sensitive=False), - default="FC", - ) - san_ip = click.prompt( - "Management IP/FQDN", type=str, value_proc=self._validate_ip_or_fqdn - ) - - # Main array credentials (will be automatically converted to Juju secret) - console.print("\n[bold yellow]Array Credentials[/bold yellow]") - console.print( - "These credentials will be automatically stored in a Juju secret." - ) - san_username = click.prompt("SAN username", type=str, default="maintenance") - san_password = click.prompt("SAN password", type=str, hide_input=True) - - # Optional: prompt for volume backend name (defaults to backend name) - volume_backend_name = click.prompt( - "Volume backend name", type=str, default=backend_name, show_default=True - ) - - # Optional: CHAP authentication for iSCSI - chap_username = "" - chap_password = "" - use_chap_auth = False - if protocol.lower() == "iscsi": - use_chap_auth = click.confirm( - "\nUse CHAP authentication for iSCSI?", default=False - ) - if use_chap_auth: - console.print("[bold yellow]CHAP Credentials[/bold yellow]") - console.print( - "These credentials will be automatically stored in a Juju secret." - ) - chap_username = click.prompt("CHAP username", type=str) - chap_password = click.prompt("CHAP password", type=str, hide_input=True) - - # Optional: Mirror/GAD configuration - hitachi_mirror_chap_username = "" - hitachi_mirror_chap_password = "" - hitachi_mirror_rest_username = "" - hitachi_mirror_rest_password = "" - - configure_mirror = click.confirm( - "\nConfigure mirror/replication (GAD) settings?", default=False - ) - if configure_mirror: - console.print( - "\n[bold yellow]Mirror/Replication Configuration[/bold yellow]" - ) - - # Mirror CHAP credentials - if click.confirm("Configure mirror CHAP credentials?", default=False): - console.print("[bold yellow]Mirror CHAP Credentials[/bold yellow]") - console.print( - "These credentials will be automatically stored in a Juju secret." - ) - hitachi_mirror_chap_username = click.prompt( - "Mirror CHAP username", type=str - ) - hitachi_mirror_chap_password = click.prompt( - "Mirror CHAP password", type=str, hide_input=True - ) - - # Mirror REST API credentials - if click.confirm("Configure mirror REST API credentials?", default=False): - console.print("[bold yellow]Mirror REST API Credentials[/bold yellow]") - console.print( - "These credentials will be automatically stored in a Juju secret." - ) - hitachi_mirror_rest_username = click.prompt( - "Mirror REST API username", type=str - ) - hitachi_mirror_rest_password = click.prompt( - "Mirror REST API password", type=str, hide_input=True - ) - - return HitachiConfig( - name=backend_name, - hitachi_storage_id=hitachi_storage_id, - hitachi_pools=hitachi_pools, - protocol=protocol, - san_ip=san_ip, - san_username=san_username, - san_password=san_password, - volume_backend_name=volume_backend_name, - use_chap_auth=use_chap_auth, - chap_username=chap_username, - chap_password=chap_password, - hitachi_mirror_chap_username=hitachi_mirror_chap_username, - hitachi_mirror_chap_password=hitachi_mirror_chap_password, - hitachi_mirror_rest_username=hitachi_mirror_rest_username, - hitachi_mirror_rest_password=hitachi_mirror_rest_password, - ) - - # Implementation of abstract methods from StorageBackendBase - def create_deploy_step( - self, - deployment: Deployment, - client, - tfhelper: TerraformHelper, - jhelper: JujuHelper, - manifest: Manifest, - backend_name: str, - backend_config: StorageBackendConfig, - model: str, - ) -> BaseStep: - """Create a deployment step for Hitachi backend.""" - return HitachiDeployStep( - deployment, - client, - tfhelper, - jhelper, - manifest, - backend_name, - backend_config, - self, - model, - ) - - def create_destroy_step( - self, - deployment: Deployment, - client, - tfhelper: TerraformHelper, - jhelper: JujuHelper, - manifest: Manifest, - backend_name: str, - model: str, - ) -> BaseStep: - """Create a destruction step for Hitachi backend.""" - return HitachiDestroyStep( - deployment, - client, - tfhelper, - jhelper, - manifest, - backend_name, - self, - model, - ) - - def create_update_config_step( - self, - deployment: Deployment, - backend_name: str, - config_updates: Dict[str, Any], - ) -> BaseStep: - """Create a configuration update step for Hitachi backend.""" - return HitachiUpdateConfigStep( - deployment, - self, - backend_name, - config_updates, - ) + def charm_name(self) -> str: + """Return the charm name for this backend.""" + return "cinder-volume-hitachi" + @property + def charm_channel(self) -> str: + """Return the charm channel for this backend.""" + return "latest/edge" -# Hitachi-specific step implementations using base step classes -class HitachiDeployStep(BaseStorageBackendDeployStep): - """Deploy Hitachi storage backend using base step class.""" + @property + def charm_revision(self) -> str | None: + """Return the charm revision for this backend.""" + return None - def get_terraform_variables(self) -> Dict[str, Any]: - """Get Terraform variables for Hitachi backend deployment.""" - return self.backend_instance.get_terraform_variables( - self.backend_name, self.backend_config, self.model - ) + @property + def charm_base(self) -> str: + """Return the charm base for this backend.""" + return "ubuntu@24.04" + @property + def backend_endpoint(self) -> str: + """Return the backend endpoint for this backend.""" + return "cinder-volume" -class HitachiDestroyStep(BaseStorageBackendDestroyStep): - """Destroy Hitachi storage backend using base step class.""" + @property + def units(self) -> int: + """Return the number of units for this backend.""" + return 1 + @property + def additional_integrations(self) -> list[str]: + """Return a list of additional integrations for this backend.""" + return [] -class HitachiUpdateConfigStep(BaseStorageBackendConfigUpdateStep): - """Update Hitachi storage backend configuration using base step class.""" + def config_type(self) -> type[StorageBackendConfig]: + """Return the configuration class for Hitachi backend.""" + return HitachiConfig diff --git a/sunbeam-python/sunbeam/storage/backends/hitachi/cli.py b/sunbeam-python/sunbeam/storage/backends/hitachi/cli.py deleted file mode 100644 index e06effd76..000000000 --- a/sunbeam-python/sunbeam/storage/backends/hitachi/cli.py +++ /dev/null @@ -1,344 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -"""CLI functionality for Hitachi storage backend. - -This module contains all CLI-related code moved from backend.py, -including command registration and helper functions. -""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any, Dict, Optional - -import click -import pydantic -from rich.console import Console - -try: - import yaml as _yaml # type: ignore - - yaml: Any = _yaml -except Exception: # yaml optional; handle gracefully at runtime - yaml = None - -from sunbeam.core.deployment import Deployment -from sunbeam.storage.backends.hitachi.backend import HitachiBackend -from sunbeam.storage.service import StorageBackendService - -console = Console() - - -class HitachiCLI: - """CLI functionality for Hitachi storage backend.""" - - def __init__(self, backend: HitachiBackend): - self.backend = backend - - def _load_config_file(self, path: Optional[Path]) -> Dict[str, Any]: - """Load YAML or JSON config file into a dictionary. - - YAML is preferred if PyYAML is available, otherwise JSON is used. - """ - if not path: - return {} - text = path.read_text() - if yaml is not None: - return dict(yaml.safe_load(text) or {}) - - return dict(json.loads(text)) - - def register_add_cli(self, add: click.Group) -> None: # noqa: C901 - """Register 'sunbeam storage add hitachi'. - - Includes typed options and a --config-file flag. - """ - - def _click_type_for(field_info) -> click.types.ParamType: - # Map pydantic field to Click type - ann = getattr(field_info, "annotation", None) - typ = None - if ann is not None: - typ = str(ann) - elif hasattr(field_info, "type_"): - typ = str(field_info.type_) - if typ and ("int" in typ): - return click.INT - if typ and ("float" in typ): - return click.FLOAT - if typ and ("bool" in typ): - return click.BOOL - return click.STRING - - def _build_params(required_all: bool) -> list: - params: list = [] - # name option (not required; prompt in interactive mode) - params.append( - click.Option(["--name"], type=str, required=False, help="Backend name") - ) - # config file (optional) - params.append( - click.Option( - ["--config-file"], - type=click.Path(exists=True, dir_okay=False, path_type=Path), - required=False, - help="YAML/JSON config file", - ) - ) - # Model-derived options - fields = getattr(self.backend.config_class, "model_fields", {}) - for fname, finfo in fields.items(): - if fname == "name": - continue - opt_name = "--" + fname.replace("_", "-") - click_type = _click_type_for(finfo) - # Determine requiredness for add (respect model) - # For interactive UX, keep CLI options optional; the model - # enforces requiredness. - is_required = False - # Help text - descr = None - if hasattr(finfo, "field_info") and hasattr( - finfo.field_info, "description" - ): - descr = finfo.field_info.description - elif hasattr(finfo, "description"): - descr = finfo.description - params.append( - click.Option( - [opt_name], type=click_type, required=is_required, help=descr - ) - ) - return params - - def _build_config_from_kwargs(kwargs: Dict[str, Any]) -> Dict[str, Any]: - # Extract name and field values from kwargs - # (Click converts dashes to underscores) - cfg: Dict[str, Any] = {} - for k, v in kwargs.items(): - if v is None: - continue - cfg[k] = v - return cfg - - def add_callback(**kwargs): - deployment: Deployment = click.get_current_context().obj - cfg_file = kwargs.pop("config_file", None) - file_cfg = self._load_config_file(cfg_file) - cli_cfg = _build_config_from_kwargs(kwargs) - # Determine if interactive: no config-file and no CLI options supplied - provided_cli_values = {k: v for k, v in cli_cfg.items() if v is not None} - interactive = not file_cfg and not provided_cli_values - - if interactive: - # Prompt for name and full config via helper - console.print( - f"[blue]Setting up {self.backend.display_name} backend[/blue]" - ) - backend_name = click.prompt("Backend name", type=str) - config_instance = self.backend.prompt_for_config(backend_name) - config_instance.name = backend_name - self.backend.add_backend( - deployment, backend_name, config_instance, console - ) - return - - # Non-interactive path: merge file and CLI, validate - merged = {**file_cfg, **provided_cli_values} - try: - config_instance = self.backend.config_class(**merged) - except pydantic.ValidationError as e: - console.print("[red]Configuration validation error:[/red]") - for error in e.errors(): - field_name = error.get("loc", ["unknown"])[0] - # Convert field name to CLI parameter format - cli_param = f"--{field_name.replace('_', '-')}" - console.print(f" {cli_param}: {error['msg']}") - raise click.Abort() - backend_name = merged.get("name") - if not backend_name: - raise click.BadParameter( - "--name is required when not running interactively" - ) - self.backend.add_backend(deployment, backend_name, config_instance, console) - - # Build command dynamically with parameters - params = _build_params(required_all=False) - help_text = ( - f"Add {self.backend.display_name} backend.\n\n" - "Behavior:\n" - "- If no options are provided, runs in interactive mode and prompts " - "for all required fields.\n" - "- If options and/or --config-file are provided, runs non-" - "interactively and validates against the model.\n" - "- In non-interactive mode, --name is required (or supplied via " - "--config-file).\n\n" - "Examples:\n" - " sunbeam storage add hitachi\n" - " sunbeam storage add hitachi --config-file hitachi.yaml\n" - " sunbeam storage add hitachi --name myhitachi --san-ip 10.0.0.10 " - "--protocol FC\n" - ) - cmd = click.Command( - name=self.backend.name, - params=params, - callback=add_callback, - help=help_text, - ) - add.add_command(cmd) - - def register_cli( # noqa: C901 - self, - remove: click.Group, - config_show: click.Group, - config_set: click.Group, - config_options: click.Group, - deployment: Deployment, - ) -> None: - """Register management commands for Hitachi backend.""" - - @click.command(name=self.backend.name) - @click.argument("backend_name", type=str) - @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") - @click.pass_context - def remove_hitachi(ctx, backend_name: str, yes: bool): - service = self.backend._get_service(deployment) - if not service.backend_exists(backend_name, self.backend.name): - console.print(f"[red]Error: Backend '{backend_name}' not found[/red]") - raise click.Abort() - if not yes: - click.confirm( - f"Remove {self.backend.display_name} backend '{backend_name}'?", - abort=True, - ) - try: - self.backend.remove_backend(deployment, backend_name, console) - except Exception as e: - console.print(f"[red]Error removing backend: {e}[/red]") - raise click.Abort() - - remove.add_command(remove_hitachi) - - @click.command(name=self.backend.name) - @click.argument("backend_name", type=str) - @click.pass_context - def config_show_hitachi(ctx, backend_name: str): - service = self.backend._get_service(deployment) - config = service.get_backend_config(backend_name, self.backend.name) - self.backend.display_config_table(backend_name, config) - - config_show.add_command(config_show_hitachi) - - # Build typed options for config set (only provided options are updated) - def _build_set_params() -> list: - params = [] - # Add config-file option first - params.append( - click.Option( - ["--config-file"], - type=click.Path( - exists=True, dir_okay=False, readable=True, path_type=Path - ), - required=False, - help="YAML/JSON config file with updates", - ) - ) - fields = getattr(self.backend.config_class, "model_fields", {}) - for fname, finfo in fields.items(): - if fname == "name": - continue - opt = "--" + fname.replace("_", "-") - # For updates, make everything optional with default None - # so we can detect presence - click_type: click.ParamType = click.STRING - ann = getattr(finfo, "annotation", None) - if ann is not None and ("bool" in str(ann)): - click_type = click.BOOL - elif ann is not None and ("int" in str(ann)): - click_type = click.INT - elif ann is not None and ("float" in str(ann)): - click_type = click.FLOAT - descr = None - if hasattr(finfo, "field_info") and hasattr( - finfo.field_info, "description" - ): - descr = finfo.field_info.description - elif hasattr(finfo, "description"): - descr = finfo.description - params.append( - click.Option( - [opt], type=click_type, required=False, default=None, help=descr - ) - ) - return params - - def set_callback(backend_name: str, **kwargs): - cfg_file = kwargs.pop("config_file", None) - file_cfg = {} - if cfg_file: - file_cfg = self._load_config_file(cfg_file) - # Only include keys that were explicitly provided (value not None) - updates = {k: v for k, v in kwargs.items() if v is not None} - updates = {**file_cfg, **updates} - try: - # Get current configuration and merge with updates for validation - service = StorageBackendService(deployment) - current_config = service.get_backend_config( - backend_name, self.backend.name - ) - - # Create a merged config for validation - # Current config comes from charm (kebab-case keys), - # convert to snake_case - merged_config = {} - for key, value in current_config.items(): - snake_key = key.replace("-", "_") - merged_config[snake_key] = value - - # Updates from CLI are already in snake_case - # (Click converts --protocol to protocol) - merged_config.update(updates) - merged_config["name"] = backend_name # Ensure name is set - _ = self.backend.config_class(**merged_config) - except pydantic.ValidationError as e: - console.print("[red]Configuration validation error:[/red]") - for error in e.errors(): - field_name = error["loc"][0] if error.get("loc") else "unknown" - # Convert field name to CLI parameter format - cli_param = f"--{str(field_name).replace('_', '-')}" - console.print(f" {cli_param}: {error['msg']}") - raise click.ClickException( - "Configuration update failed due to validation error" - ) - try: - self.backend.update_backend_config(deployment, backend_name, updates) - console.print( - ( - f"[green]Configuration updated for {self.backend.display_name} " - f"backend '{backend_name}'[/green]" - ) - ) - except Exception as e: - console.print(f"[red]Failed to update configuration: {e}[/red]") - raise click.ClickException(f"Configuration update failed: {e}") - - set_params = _build_set_params() - # Add backend_name as a required argument - set_params.insert(0, click.Argument(["backend_name"], type=str, required=True)) - set_cmd = click.Command( - name=self.backend.name, - params=set_params, - callback=set_callback, - help="Set configuration options", - ) - config_set.add_command(set_cmd) - - @click.command(name=self.backend.name) - @click.argument("backend_name", type=str, required=False) - @click.pass_context - def config_options_hitachi(ctx, backend_name: str | None = None): - self.backend.display_config_options() - - config_options.add_command(config_options_hitachi) diff --git a/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/main.tf b/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/main.tf deleted file mode 100644 index ee0e566d3..000000000 --- a/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/main.tf +++ /dev/null @@ -1,222 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -terraform { - required_providers { - juju = { - source = "juju/juju" - version = "= 0.20.0" - } - } -} - -provider "juju" {} - -data "juju_model" "machine_model" { - name = var.machine_model -} - -# Create Juju secrets for Hitachi backend credentials -# Main array credentials (required for all backends) -resource "juju_secret" "hitachi_san_credentials" { - for_each = { - for backend_name, backend_config in var.hitachi_backends : - backend_name => backend_config - if backend_config.san_username != "" && backend_config.san_password != "" - } - - model = data.juju_model.machine_model.name - name = "${each.key}-san-credentials" - value = { - username = each.value.san_username - password = each.value.san_password - } -} - -# CHAP credentials (optional, only for iSCSI with CHAP auth) -resource "juju_secret" "hitachi_chap_credentials" { - for_each = { - for backend_name, backend_config in var.hitachi_backends : - backend_name => backend_config - if backend_config.use_chap_auth == true && backend_config.chap_username != "" && backend_config.chap_password != "" - } - - model = data.juju_model.machine_model.name - name = "${each.key}-chap-credentials" - value = { - username = each.value.chap_username - password = each.value.chap_password - } -} - -# Mirror CHAP credentials (optional, for GAD replication) -resource "juju_secret" "hitachi_mirror_chap_credentials" { - for_each = { - for backend_name, backend_config in var.hitachi_backends : - backend_name => backend_config - if backend_config.hitachi_mirror_chap_username != "" && backend_config.hitachi_mirror_chap_password != "" - } - - model = data.juju_model.machine_model.name - name = "${each.key}-mirror-chap-credentials" - value = { - username = each.value.hitachi_mirror_chap_username - password = each.value.hitachi_mirror_chap_password - } -} - -# Mirror REST API credentials (optional, for GAD replication) -resource "juju_secret" "hitachi_mirror_rest_credentials" { - for_each = { - for backend_name, backend_config in var.hitachi_backends : - backend_name => backend_config - if backend_config.hitachi_mirror_rest_username != "" && backend_config.hitachi_mirror_rest_password != "" - } - - model = data.juju_model.machine_model.name - name = "${each.key}-mirror-rest-credentials" - value = { - username = each.value.hitachi_mirror_rest_username - password = each.value.hitachi_mirror_rest_password - } -} - -# Grant access to secrets for Hitachi backend applications -resource "juju_access_secret" "hitachi_san_credentials_access" { - for_each = { - for backend_name, backend_config in var.hitachi_backends : - backend_name => backend_config - if backend_config.san_username != "" && backend_config.san_password != "" - } - - model = data.juju_model.machine_model.name - secret_id = juju_secret.hitachi_san_credentials[each.key].secret_id - applications = [each.key] - - # Ensure proper dependency ordering to avoid provider bugs - depends_on = [juju_application.hitachi_backends] - - lifecycle { - # Prevent destruction when applications list becomes empty - prevent_destroy = false - # Create before destroy to avoid empty applications list state - create_before_destroy = true - } -} - -resource "juju_access_secret" "hitachi_chap_credentials_access" { - for_each = { - for backend_name, backend_config in var.hitachi_backends : - backend_name => backend_config - if backend_config.use_chap_auth == true && backend_config.chap_username != "" && backend_config.chap_password != "" - } - - model = data.juju_model.machine_model.name - secret_id = juju_secret.hitachi_chap_credentials[each.key].secret_id - applications = [each.key] - - # Ensure proper dependency ordering to avoid provider bugs - depends_on = [juju_application.hitachi_backends] - - lifecycle { - # Prevent destruction when applications list becomes empty - prevent_destroy = false - # Create before destroy to avoid empty applications list state - create_before_destroy = true - } -} - -resource "juju_access_secret" "hitachi_mirror_chap_credentials_access" { - for_each = { - for backend_name, backend_config in var.hitachi_backends : - backend_name => backend_config - if backend_config.hitachi_mirror_chap_username != "" && backend_config.hitachi_mirror_chap_password != "" - } - - model = data.juju_model.machine_model.name - secret_id = juju_secret.hitachi_mirror_chap_credentials[each.key].secret_id - applications = [each.key] - - # Ensure proper dependency ordering to avoid provider bugs - depends_on = [juju_application.hitachi_backends] - - lifecycle { - # Prevent destruction when applications list becomes empty - prevent_destroy = false - # Create before destroy to avoid empty applications list state - create_before_destroy = true - } -} - -resource "juju_access_secret" "hitachi_mirror_rest_credentials_access" { - for_each = { - for backend_name, backend_config in var.hitachi_backends : - backend_name => backend_config - if backend_config.hitachi_mirror_rest_username != "" && backend_config.hitachi_mirror_rest_password != "" - } - - model = data.juju_model.machine_model.name - secret_id = juju_secret.hitachi_mirror_rest_credentials[each.key].secret_id - applications = [each.key] - - # Ensure proper dependency ordering to avoid provider bugs - depends_on = [juju_application.hitachi_backends] - - lifecycle { - # Prevent destruction when applications list becomes empty - prevent_destroy = false - # Create before destroy to avoid empty applications list state - create_before_destroy = true - } -} - -# Deploy Hitachi storage backend charms -resource "juju_application" "hitachi_backends" { - for_each = var.hitachi_backends - - name = each.key - model = data.juju_model.machine_model.name - units = 1 - - charm { - name = var.charm_hitachi_name - channel = var.charm_hitachi_channel - revision = var.charm_hitachi_revision - base = var.charm_hitachi_base - } - - config = merge({ - volume-backend-name = each.key - }, each.value.charm_config, { - # Main array credentials - always required - san-credentials-secret = contains(keys(juju_secret.hitachi_san_credentials), each.key) ? juju_secret.hitachi_san_credentials[each.key].secret_id : "" - - # CHAP credentials - only if CHAP auth is enabled and credentials provided - chap-credentials-secret = contains(keys(juju_secret.hitachi_chap_credentials), each.key) ? juju_secret.hitachi_chap_credentials[each.key].secret_id : "" - - # Mirror CHAP credentials - only if mirror CHAP credentials provided - hitachi-mirror-chap-credentials-secret = contains(keys(juju_secret.hitachi_mirror_chap_credentials), each.key) ? juju_secret.hitachi_mirror_chap_credentials[each.key].secret_id : "" - - # Mirror REST API credentials - only if mirror REST credentials provided - hitachi-mirror-rest-credentials-secret = contains(keys(juju_secret.hitachi_mirror_rest_credentials), each.key) ? juju_secret.hitachi_mirror_rest_credentials[each.key].secret_id : "" - }) - - endpoint_bindings = var.endpoint_bindings -} - -# Integrate Hitachi backends with main cinder-volume -resource "juju_integration" "hitachi_to_cinder_volume" { - for_each = var.hitachi_backends - - model = var.machine_model - - application { - name = juju_application.hitachi_backends[each.key].name - endpoint = var.charm_hitachi_endpoint - } - - application { - name = "cinder-volume" - endpoint = var.charm_hitachi_endpoint - } -} diff --git a/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/outputs.tf b/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/outputs.tf deleted file mode 100644 index 493c8151b..000000000 --- a/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/outputs.tf +++ /dev/null @@ -1,13 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -output "hitachi_backend_applications" { - description = "Map of deployed Hitachi backend applications" - value = { - for name, app in juju_application.hitachi_backends : name => { - name = app.name - model = app.model - units = app.units - } - } -} diff --git a/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/test-variables.tfvars b/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/test-variables.tfvars deleted file mode 100644 index 7a07fb91a..000000000 --- a/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/test-variables.tfvars +++ /dev/null @@ -1,35 +0,0 @@ -machine_model = "openstack" -charm_hitachi_channel = "latest/edge" -charm_hitachi_revision = null - -hitachi_backends = { - "hitachi-backend-1" = { - charm_config = { - "hitachi-storage-id" = "123456" - "hitachi-pools" = "pool1,pool2" - "san-ip" = "192.168.1.100" - "protocol" = "FC" - "volume-backend-name" = "hitachi-backend-1" - } - - # Main array credentials (always required) - san_username = "maintenance" - san_password = "secret123" - - # CHAP credentials (optional - using iSCSI example) - use_chap_auth = true - chap_username = "iscsi_user" - chap_password = "iscsi_pass" - - # Mirror CHAP credentials (optional) - hitachi_mirror_chap_username = "mirror_chap_user" - hitachi_mirror_chap_password = "mirror_chap_pass" - - # Mirror REST API credentials (optional) - hitachi_mirror_rest_username = "mirror_rest_user" - hitachi_mirror_rest_password = "mirror_rest_pass" - } -} - -machine_ids = [] -endpoint_bindings = null diff --git a/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/variables.tf b/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/variables.tf deleted file mode 100644 index 64efd6261..000000000 --- a/sunbeam-python/sunbeam/storage/backends/hitachi/deploy-hitachi-backend/variables.tf +++ /dev/null @@ -1,74 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -variable "machine_model" { - description = "Name of the machine model to deploy to" - type = string -} - -variable "charm_hitachi_name" { - description = "Name of the Hitachi charm" - type = string - default = "cinder-volume-hitachi" -} - -variable "charm_hitachi_base" { - description = "Base for the Hitachi charm" - type = string - default = "ubuntu@24.04" -} - -variable "charm_hitachi_channel" { - description = "Operator channel for Hitachi backend deployment" - type = string - default = "latest/edge" -} - -variable "charm_hitachi_endpoint" { - description = "Endpoint name for Hitachi backend integration" - type = string - default = "cinder-volume" -} - -variable "charm_hitachi_revision" { - description = "Operator channel revision for Hitachi backend deployment" - type = number - default = null -} - -variable "hitachi_backends" { - description = "Map of Hitachi backend configurations" - type = map(object({ - charm_config = map(string) - - # Main array credentials (required) - san_username = string - san_password = string - - # CHAP credentials (optional) - use_chap_auth = optional(bool, false) - chap_username = optional(string, "") - chap_password = optional(string, "") - - # Mirror CHAP credentials (optional) - hitachi_mirror_chap_username = optional(string, "") - hitachi_mirror_chap_password = optional(string, "") - - # Mirror REST API credentials (optional) - hitachi_mirror_rest_username = optional(string, "") - hitachi_mirror_rest_password = optional(string, "") - })) - default = {} -} - -variable "machine_ids" { - description = "List of machine ids to include" - type = list(string) - default = [] -} - -variable "endpoint_bindings" { - description = "Endpoint bindings for the applications" - type = set(map(string)) - default = null -} diff --git a/sunbeam-python/sunbeam/storage/backends/purestorage/__init__.py b/sunbeam-python/sunbeam/storage/backends/purestorage/__init__.py new file mode 100644 index 000000000..45db76b8f --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/purestorage/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""PureStorage backend for Sunbeam storage.""" diff --git a/sunbeam-python/sunbeam/storage/backends/purestorage/backend.py b/sunbeam-python/sunbeam/storage/backends/purestorage/backend.py index 8c9b513c3..c7ed1691a 100644 --- a/sunbeam-python/sunbeam/storage/backends/purestorage/backend.py +++ b/sunbeam-python/sunbeam/storage/backends/purestorage/backend.py @@ -4,38 +4,32 @@ """Pure Storage FlashArray backend implementation using base step classes.""" import logging -from typing import Any, Dict, Literal, Set +from enum import StrEnum +from typing import Annotated, Literal -import click - -try: - import yaml as _yaml # type: ignore - - yaml: Any = _yaml -except Exception: # yaml optional; handle gracefully at runtime - yaml = None - -# Import pydantic Field directly from pydantic import Field from rich.console import Console -from sunbeam.core.common import BaseStep -from sunbeam.core.deployment import Deployment -from sunbeam.core.juju import JujuHelper -from sunbeam.core.manifest import Manifest -from sunbeam.core.terraform import TerraformHelper +from sunbeam.core.manifest import StorageBackendConfig from sunbeam.storage.base import StorageBackendBase -from sunbeam.storage.models import StorageBackendConfig -from sunbeam.storage.steps import ( - BaseStorageBackendConfigUpdateStep, - BaseStorageBackendDeployStep, - BaseStorageBackendDestroyStep, -) +from sunbeam.storage.models import SecretDictField LOG = logging.getLogger(__name__) console = Console() +class Personality(StrEnum): + """Enumeration of valid host personality types.""" + + AIX = "aix" + ESXI = "esxi" + HITACHI_VSP = "hitachi-vsp" + HPUX = "hpux" + ORACLE_VM_SERVER = "oracle-vm-server" + SOLARIS = "solaris" + VMS = "vms" + + class PureStorageConfig(StorageBackendConfig): """Configuration model for Pure Storage FlashArray backend. @@ -44,364 +38,155 @@ class PureStorageConfig(StorageBackendConfig): through the charm configuration system. """ - # Required fields (inherited from StorageBackendConfig) - # name: str (from base class) - # Mandatory connection parameters - san_ip: str = Field( - ..., description="Pure Storage FlashArray management IP or hostname" - ) - pure_api_token: str = Field( - ..., description="REST API authorization token from FlashArray" - ) - protocol: Literal["iscsi", "fc", "nvme"] = Field( - default="fc", description="Pure Storage protocol (iscsi, fc, nvme)" - ) + san_ip: Annotated[ + str, Field(description="Pure Storage FlashArray management IP or hostname") + ] + pure_api_token: Annotated[ + str, + Field(description="REST API authorization token from FlashArray"), + SecretDictField(field="token"), + ] # Optional backend configuration - volume_backend_name: str = Field( - default="", description="Name that Cinder will report for this backend" - ) - backend_availability_zone: str = Field( - default="", description="Availability zone to associate with this backend" - ) - + protocol: Annotated[ + Literal["iscsi", "fc", "nvme"] | None, + Field(description="Pure Storage protocol (iscsi, fc, nvme)"), + ] = None # Protocol-specific options - pure_iscsi_cidr: str = Field( - default="0.0.0.0/0", - description="CIDR of FlashArray iSCSI targets hosts can connect to", - ) - pure_iscsi_cidr_list: str = Field( - default="", description="Comma-separated list of CIDR for iSCSI targets" - ) - pure_nvme_cidr: str = Field( - default="0.0.0.0/0", - description="CIDR of FlashArray NVMe targets hosts can connect to", - ) - pure_nvme_cidr_list: str = Field( - default="", description="Comma-separated list of CIDR for NVMe targets" - ) - pure_nvme_transport: Literal["roce", "tcp"] = Field( - default="roce", description="NVMe transport layer (roce or tcp)" - ) + pure_iscsi_cidr: Annotated[ + str | None, + Field( + description="CIDR of FlashArray iSCSI targets hosts can connect to", + ), + ] = None + pure_iscsi_cidr_list: Annotated[ + str | None, + Field(description="Comma-separated list of CIDR for iSCSI targets"), + ] = None + pure_nvme_cidr: Annotated[ + str | None, + Field(description="CIDR of FlashArray NVMe targets hosts can connect to"), + ] = None + + pure_nvme_cidr_list: Annotated[ + str | None, + Field(description="Comma-separated list of CIDR for NVMe targets"), + ] = None + pure_nvme_transport: Annotated[ + Literal["tcp"] | None, # note(gboutry): roce not supported yet + Field(description="NVMe transport layer"), + ] = None # Host and protocol tuning - pure_host_personality: str = Field( - default="", description="Host personality for protocol tuning" - ) + pure_host_personality: Annotated[ + Personality | None, Field(description="Host personality for protocol tuning") + ] = None # Storage management - pure_automatic_max_oversubscription_ratio: bool = Field( - default=True, description="Automatically determine oversubscription ratio" - ) - pure_eradicate_on_delete: bool = Field( - default=False, - description="Immediately eradicate volumes on delete " - "(WARNING: not recoverable)", - ) + pure_automatic_max_oversubscription_ratio: Annotated[ + bool | None, + Field(description="Automatically determine oversubscription ratio"), + ] = None + pure_eradicate_on_delete: Annotated[ + bool | None, + Field( + description="Immediately eradicate volumes on delete " + "(WARNING: not recoverable)", + ), + ] = None # Replication settings - pure_replica_interval_default: int = Field( - default=3600, description="Snapshot replication interval in seconds" - ) - pure_replica_retention_short_term_default: int = Field( - default=14400, - description="Retain all snapshots on target for this time (seconds)", - ) - pure_replica_retention_long_term_per_day_default: int = Field( - default=3, description="Retain how many snapshots for each day" - ) - pure_replica_retention_long_term_default: int = Field( - default=7, description="Retain snapshots per day on target for this time (days)" - ) - pure_replication_pg_name: str = Field( - default="cinder-group", - description="Pure Protection Group name for async replication", - ) - pure_replication_pod_name: str = Field( - default="cinder-pod", description="Pure Pod name for sync replication" - ) + pure_replica_interval_default: Annotated[ + int | None, Field(description="Snapshot replication interval in seconds") + ] = None + pure_replica_retention_short_term_default: Annotated[ + int | None, + Field(description="Retain all snapshots on target for this time (seconds)"), + ] = None + pure_replica_retention_long_term_per_day_default: Annotated[ + int | None, Field(description="Retain how many snapshots for each day") + ] = None + pure_replica_retention_long_term_default: Annotated[ + int | None, + Field(description="Retain snapshots per day on target for this time (days)"), + ] = None + pure_replication_pg_name: Annotated[ + str | None, + Field( + description="Pure Protection Group name for async replication", + ), + ] = None + pure_replication_pod_name: Annotated[ + str | None, + Field(description="Pure Pod name for sync replication"), + ] = None # Advanced replication - pure_trisync_enabled: bool = Field( - default=False, description="Enable 3-site replication (sync + async)" - ) - pure_trisync_pg_name: str = Field( - default="cinder-trisync", - description="Protection Group name for trisync replication", - ) + pure_trisync_enabled: Annotated[ + bool | None, + Field(description="Enable 3-site replication (sync + async)"), + ] = None + pure_trisync_pg_name: Annotated[ + str | None, + Field(description="Protection Group name for trisync replication"), + ] = None # SSL and security - driver_ssl_cert_verify: bool = Field( - default=False, description="Enable SSL certificate verification" - ) - driver_ssl_cert_path: str = Field( - default="", description="Path to SSL certificate file or directory" - ) + driver_ssl_cert: Annotated[ + str | None, Field(description="SSL certificate content in PEM format") + ] = None # Performance options - use_multipath_for_image_xfer: bool = Field( - default=True, description="Enable multipathing for image transfer operations" - ) + use_multipath_for_image_xfer: Annotated[ + bool | None, + Field( + description="Enable multipathing for image transfer operations", + ), + ] = None class PureStorageBackend(StorageBackendBase): """Pure Storage FlashArray backend implementation.""" - name = "purestorage" + backend_type = "purestorage" display_name = "Pure Storage FlashArray" - charm_name = "cinder-volume-purestorage" - - def __init__(self): - """Initialize Pure Storage backend.""" - super().__init__() - self.tfplan = "purestorage-backend-plan" - self.tfplan_dir = "deploy-purestorage-backend" - - charm_channel = ( - "latest/edge" # Use edge for development, change to stable for production - ) - charm_revision = None # Let Juju pick the latest - charm_base = "ubuntu@24.04" - backend_endpoint = "cinder-volume" - units = 1 - additional_integrations = [] @property - def config_class(self) -> type[StorageBackendConfig]: - """Return the configuration class for Pure Storage backend.""" - return PureStorageConfig - - def _get_credential_fields(self) -> Set[str]: - """Get set of credential field names that should be excluded from charm config. - - For Pure Storage backend, we only exclude the meta name field. - """ - return {"name"} # Pure Storage uses API token directly in config - - def get_field_mapping(self) -> Dict[str, str]: - """Get mapping from config fields to charm config options. - - Uses base class automatic mapping and excludes credential fields. - """ - # Start from the base automatic mapping - mapping = super().get_field_mapping() - - # Exclude credential fields - exclude = self._get_credential_fields() - return {k: v for k, v in mapping.items() if k not in exclude} - - # CLI registration uses base class implementation - - def get_terraform_variables( - self, backend_name: str, config: StorageBackendConfig, model: str - ) -> Dict[str, Any]: - """Generate Terraform variables for Pure Storage backend deployment.""" - # Map our configuration fields to the correct charm configuration option names - config_dict = config.model_dump() - field_mapping = self.get_field_mapping() - - # Filter config using base class method - charm_config = self._filter_config_for_charm( - config_dict, field_mapping, exclude_fields=self._get_credential_fields() - ) - - # Build Terraform variables to match the plan's expected format - tfvars = { - "machine_model": model, - "charm_purestorage_name": self.charm_name, - "charm_purestorage_base": self.charm_base, - "charm_purestorage_channel": self.charm_channel, - "charm_purestorage_endpoint": self.backend_endpoint, - "charm_purestorage_revision": self.charm_revision, - "purestorage_backends": { - backend_name: { - "charm_config": charm_config, - } - }, - } - - return tfvars - - def _get_default_config(self) -> PureStorageConfig: - """Get a default configuration instance for comparison.""" - return PureStorageConfig( - name="dummy", - san_ip="dummy", - pure_api_token="dummy", # noqa: S106 - ) - - def prompt_for_config(self, backend_name: str) -> PureStorageConfig: - """Prompt user for Pure Storage-specific configuration.""" - return self._prompt_for_config(backend_name) - - # IP/FQDN validation uses base class implementation - - def _prompt_for_config(self, backend_name: str) -> PureStorageConfig: - """Prompt user for Pure Storage backend configuration.""" - console.print( - "\n[bold blue]Pure Storage FlashArray Backend Configuration[/bold blue]" - ) - console.print("Please provide the required configuration options:") - - # Prompt for required fields - san_ip = click.prompt( - "FlashArray management IP/FQDN", - type=str, - value_proc=self._validate_ip_or_fqdn, - ) - pure_api_token = click.prompt("Pure API token", type=str, hide_input=True) - protocol = click.prompt( - "Protocol", - type=click.Choice(["iscsi", "fc", "nvme"], case_sensitive=False), - default="fc", - ) - - # Optional: prompt for volume backend name (defaults to backend name) - volume_backend_name = click.prompt( - "Volume backend name", type=str, default=backend_name, show_default=True - ) - - # Protocol-specific configuration - pure_iscsi_cidr = "0.0.0.0/0" - pure_nvme_cidr = "0.0.0.0/0" - pure_nvme_transport: Literal["roce", "tcp"] = "roce" - - if protocol.lower() == "iscsi": - console.print("\n[bold yellow]iSCSI Configuration[/bold yellow]") - pure_iscsi_cidr = click.prompt( - "iSCSI target CIDR", type=str, default="0.0.0.0/0", show_default=True - ) - elif protocol.lower() == "nvme": - console.print("\n[bold yellow]NVMe Configuration[/bold yellow]") - pure_nvme_cidr = click.prompt( - "NVMe target CIDR", type=str, default="0.0.0.0/0", show_default=True - ) - transport_choice = click.prompt( - "NVMe transport", - type=click.Choice(["roce", "tcp"], case_sensitive=False), - default="roce", - ) - pure_nvme_transport = transport_choice # type: ignore[assignment] - - # Optional: Host personality - pure_host_personality = "" - if click.confirm( - "\nConfigure host personality for protocol tuning?", default=False - ): - personalities = [ - "aix", - "esxi", - "hitachi-vsp", - "hpux", - "oracle-vm-server", - "solaris", - "vms", - ] - pure_host_personality = click.prompt( - "Host personality", - type=click.Choice(personalities, case_sensitive=False), - default="esxi", - ) - - # Optional: Storage management settings - pure_eradicate_on_delete = False - if click.confirm( - "\nEnable immediate volume eradication on delete?" - "(WARNING: not recoverable)", - default=False, - ): - pure_eradicate_on_delete = True - - return PureStorageConfig( - name=backend_name, - san_ip=san_ip, - pure_api_token=pure_api_token, - protocol=protocol, - volume_backend_name=volume_backend_name, - pure_iscsi_cidr=pure_iscsi_cidr, - pure_nvme_cidr=pure_nvme_cidr, - pure_nvme_transport=pure_nvme_transport, - pure_host_personality=pure_host_personality, - pure_eradicate_on_delete=pure_eradicate_on_delete, - ) - - # Implementation of abstract methods from StorageBackendBase - def create_deploy_step( - self, - deployment: Deployment, - client, - tfhelper: TerraformHelper, - jhelper: JujuHelper, - manifest: Manifest, - backend_name: str, - backend_config: StorageBackendConfig, - model: str, - ) -> BaseStep: - """Create a deployment step for Pure Storage backend.""" - return PureStorageDeployStep( - deployment, - client, - tfhelper, - jhelper, - manifest, - backend_name, - backend_config, - self, - model, - ) - - def create_destroy_step( - self, - deployment: Deployment, - client, - tfhelper: TerraformHelper, - jhelper: JujuHelper, - manifest: Manifest, - backend_name: str, - model: str, - ) -> BaseStep: - """Create a destruction step for Pure Storage backend.""" - return PureStorageDestroyStep( - deployment, - client, - tfhelper, - jhelper, - manifest, - backend_name, - self, - model, - ) - - def create_update_config_step( - self, - deployment: Deployment, - backend_name: str, - config_updates: Dict[str, Any], - ) -> BaseStep: - """Create a configuration update step for Pure Storage backend.""" - return PureStorageUpdateConfigStep( - deployment, - self, - backend_name, - config_updates, - ) + def charm_name(self) -> str: + """Return the charm name for this backend.""" + return "cinder-volume-purestorage" + @property + def charm_channel(self) -> str: + """Return the charm channel for this backend.""" + return "latest/edge" -# Pure Storage-specific step implementations using base step classes -class PureStorageDeployStep(BaseStorageBackendDeployStep): - """Deploy Pure Storage backend using base step class.""" + @property + def charm_revision(self) -> str | None: + """Return the charm revision for this backend.""" + return None - def get_terraform_variables(self) -> Dict[str, Any]: - """Get Terraform variables for Pure Storage backend deployment.""" - return self.backend_instance.get_terraform_variables( - self.backend_name, self.backend_config, self.model - ) + @property + def charm_base(self) -> str: + """Return the charm base for this backend.""" + return "ubuntu@24.04" + @property + def backend_endpoint(self) -> str: + """Return the backend endpoint for this backend.""" + return "cinder-volume" -class PureStorageDestroyStep(BaseStorageBackendDestroyStep): - """Destroy Pure Storage backend using base step class.""" + @property + def units(self) -> int: + """Return the number of units for this backend.""" + return 1 + @property + def additional_integrations(self) -> list[str]: + """Return a list of additional integrations for this backend.""" + return [] -class PureStorageUpdateConfigStep(BaseStorageBackendConfigUpdateStep): - """Update Pure Storage backend configuration using base step class.""" + def config_type(self) -> type[StorageBackendConfig]: + """Return the configuration class for Pure Storage backend.""" + return PureStorageConfig diff --git a/sunbeam-python/sunbeam/storage/backends/purestorage/cli.py b/sunbeam-python/sunbeam/storage/backends/purestorage/cli.py deleted file mode 100644 index 3fa93cd85..000000000 --- a/sunbeam-python/sunbeam/storage/backends/purestorage/cli.py +++ /dev/null @@ -1,346 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -"""CLI functionality for Pure Storage storage backend. - -This module contains all CLI-related code following the Hitachi pattern, -including command registration and helper functions. -""" - -from __future__ import annotations - -import json -import logging -from pathlib import Path -from typing import Any, Dict, Optional - -import click -import pydantic -from rich.console import Console - -try: - import yaml as _yaml # type: ignore - - yaml: Any = _yaml -except Exception: # yaml optional; handle gracefully at runtime - yaml = None - -from sunbeam.core.deployment import Deployment -from sunbeam.storage.backends.purestorage.backend import PureStorageBackend -from sunbeam.storage.service import StorageBackendService - -LOG = logging.getLogger(__name__) -console = Console() - - -class PurestorageCLI: - """CLI functionality for Pure Storage storage backend.""" - - def __init__(self, backend: PureStorageBackend): - self.backend = backend - - def _load_config_file(self, path: Optional[Path]) -> Dict[str, Any]: - """Load YAML or JSON config file into a dictionary. - - YAML is preferred if PyYAML is available, otherwise JSON is used. - """ - if not path: - return {} - text = path.read_text() - if yaml is not None: - return dict(yaml.safe_load(text) or {}) - - return dict(json.loads(text)) - - def register_add_cli(self, add: click.Group) -> None: # noqa: C901 - """Register 'sunbeam storage add purestorage'. - - Includes typed options and a --config-file flag. - """ - - def _click_type_for(field_info) -> click.types.ParamType: - # Map pydantic field to Click type - ann = getattr(field_info, "annotation", None) - typ = None - if ann is not None: - typ = str(ann) - elif hasattr(field_info, "type_"): - typ = str(field_info.type_) - if typ and ("int" in typ): - return click.INT - if typ and ("float" in typ): - return click.FLOAT - if typ and ("bool" in typ): - return click.BOOL - return click.STRING - - def _build_params(required_all: bool) -> list: - params: list = [] - # name option (not required; prompt in interactive mode) - params.append( - click.Option(["--name"], type=str, required=False, help="Backend name") - ) - # config file (optional) - params.append( - click.Option( - ["--config-file"], - type=click.Path(exists=True, dir_okay=False, path_type=Path), - required=False, - help="YAML/JSON config file", - ) - ) - # Model-derived options - fields = getattr(self.backend.config_class, "model_fields", {}) - for fname, finfo in fields.items(): - if fname == "name": - continue - opt_name = "--" + fname.replace("_", "-") - click_type = _click_type_for(finfo) - # Determine requiredness for add (respect model) - # For interactive UX, keep CLI options optional; the model - # enforces requiredness. - is_required = False - # Help text - descr = None - if hasattr(finfo, "field_info") and hasattr( - finfo.field_info, "description" - ): - descr = finfo.field_info.description - elif hasattr(finfo, "description"): - descr = finfo.description - params.append( - click.Option( - [opt_name], type=click_type, required=is_required, help=descr - ) - ) - return params - - def _build_config_from_kwargs(kwargs: Dict[str, Any]) -> Dict[str, Any]: - # Extract name and field values from kwargs - # (Click converts dashes to underscores) - cfg: Dict[str, Any] = {} - for k, v in kwargs.items(): - if v is None: - continue - cfg[k] = v - return cfg - - def add_callback(**kwargs): - deployment: Deployment = click.get_current_context().obj - cfg_file = kwargs.pop("config_file", None) - file_cfg = self._load_config_file(cfg_file) - cli_cfg = _build_config_from_kwargs(kwargs) - # Determine if interactive: no config-file and no CLI options supplied - provided_cli_values = {k: v for k, v in cli_cfg.items() if v is not None} - interactive = not file_cfg and not provided_cli_values - - if interactive: - # Prompt for name and full config via helper - console.print( - f"[blue]Setting up {self.backend.display_name} backend[/blue]" - ) - backend_name = click.prompt("Backend name", type=str) - config_instance = self.backend.prompt_for_config(backend_name) - config_instance.name = backend_name - self.backend.add_backend( - deployment, backend_name, config_instance, console - ) - return - - # Non-interactive path: merge file and CLI, validate - merged = {**file_cfg, **provided_cli_values} - try: - config_instance = self.backend.config_class(**merged) - except pydantic.ValidationError as e: - console.print("[red]Configuration validation error:[/red]") - for error in e.errors(): - field_name = error.get("loc", ["unknown"])[0] - # Convert field name to CLI parameter format - cli_param = f"--{field_name.replace('_', '-')}" - console.print(f" {cli_param}: {error['msg']}") - raise click.Abort() - backend_name = merged.get("name") - if not backend_name: - raise click.BadParameter( - "--name is required when not running interactively" - ) - self.backend.add_backend(deployment, backend_name, config_instance, console) - - # Build command dynamically with parameters - params = _build_params(required_all=False) - help_text = ( - f"Add {self.backend.display_name} backend.\n\n" - "Behavior:\n" - "- If no options are provided, runs in interactive mode and prompts " - "for all required fields.\n" - "- If options and/or --config-file are provided, runs non-" - "interactively and validates against the model.\n" - "- In non-interactive mode, --name is required (or supplied via " - "--config-file).\n\n" - "Examples:\n" - " sunbeam storage add purestorage\n" - " sunbeam storage add purestorage --config-file purestorage.yaml\n" - " sunbeam storage add purestorage --name mypure --san-ip 10.0.0.10 " - "--pure-api-token mytoken\n" - ) - cmd = click.Command( - name=self.backend.name, - params=params, - callback=add_callback, - help=help_text, - ) - add.add_command(cmd) - - def register_cli( # noqa: C901 - self, - remove: click.Group, - config_show: click.Group, - config_set: click.Group, - config_options: click.Group, - deployment: Deployment, - ) -> None: - """Register management commands for Pure Storage backend.""" - - @click.command(name=self.backend.name) - @click.argument("backend_name", type=str) - @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") - @click.pass_context - def remove_purestorage(ctx, backend_name: str, yes: bool): - service = self.backend._get_service(deployment) - if not service.backend_exists(backend_name, self.backend.name): - console.print(f"[red]Error: Backend '{backend_name}' not found[/red]") - raise click.Abort() - if not yes: - click.confirm( - f"Remove {self.backend.display_name} backend '{backend_name}'?", - abort=True, - ) - try: - self.backend.remove_backend(deployment, backend_name, console) - except Exception as e: - console.print(f"[red]Error removing backend: {e}[/red]") - raise click.Abort() - - remove.add_command(remove_purestorage) - - @click.command(name=self.backend.name) - @click.argument("backend_name", type=str) - @click.pass_context - def config_show_purestorage(ctx, backend_name: str): - service = self.backend._get_service(deployment) - config = service.get_backend_config(backend_name, self.backend.name) - self.backend.display_config_table(backend_name, config) - - config_show.add_command(config_show_purestorage) - - # Build typed options for config set (only provided options are updated) - def _build_set_params() -> list: - params = [] - # Add config-file option first - params.append( - click.Option( - ["--config-file"], - type=click.Path( - exists=True, dir_okay=False, readable=True, path_type=Path - ), - required=False, - help="YAML/JSON config file with updates", - ) - ) - fields = getattr(self.backend.config_class, "model_fields", {}) - for fname, finfo in fields.items(): - if fname == "name": - continue - opt = "--" + fname.replace("_", "-") - # For updates, make everything optional with default None - # so we can detect presence - click_type: click.ParamType = click.STRING - ann = getattr(finfo, "annotation", None) - if ann is not None and ("bool" in str(ann)): - click_type = click.BOOL - elif ann is not None and ("int" in str(ann)): - click_type = click.INT - elif ann is not None and ("float" in str(ann)): - click_type = click.FLOAT - descr = None - if hasattr(finfo, "field_info") and hasattr( - finfo.field_info, "description" - ): - descr = finfo.field_info.description - elif hasattr(finfo, "description"): - descr = finfo.description - params.append( - click.Option( - [opt], type=click_type, required=False, default=None, help=descr - ) - ) - return params - - def set_callback(backend_name: str, **kwargs): - cfg_file = kwargs.pop("config_file", None) - file_cfg = {} - if cfg_file: - file_cfg = self._load_config_file(cfg_file) - # Only include keys that were explicitly provided (value not None) - updates = {k: v for k, v in kwargs.items() if v is not None} - updates = {**file_cfg, **updates} - try: - # Get current configuration and merge with updates for validation - service = StorageBackendService(deployment) - current_config = service.get_backend_config( - backend_name, self.backend.name - ) - - # Create a merged config for validation - # Current config comes from charm (kebab-case keys), - # convert to snake_case - merged_config = {} - for key, value in current_config.items(): - snake_key = key.replace("-", "_") - merged_config[snake_key] = value - - # Updates from CLI are already in snake_case - # (Click converts --protocol to protocol) - merged_config.update(updates) - merged_config["name"] = backend_name # Ensure name is set - _ = self.backend.config_class(**merged_config) - except pydantic.ValidationError as e: - console.print("[red]Configuration validation error:[/red]") - for error in e.errors(): - field_name = error["loc"][0] if error.get("loc") else "unknown" - # Convert field name to CLI parameter format - cli_param = f"--{str(field_name).replace('_', '-')}" - console.print(f" {cli_param}: {error['msg']}") - raise click.ClickException( - "Configuration update failed due to validation error" - ) - try: - self.backend.update_backend_config(deployment, backend_name, updates) - console.print( - ( - f"[green]Configuration updated for {self.backend.display_name} " - f"backend '{backend_name}'[/green]" - ) - ) - except Exception as e: - console.print(f"[red]Failed to update configuration: {e}[/red]") - raise click.ClickException(f"Configuration update failed: {e}") - - set_params = _build_set_params() - # Add backend_name as a required argument - set_params.insert(0, click.Argument(["backend_name"], type=str, required=True)) - set_cmd = click.Command( - name=self.backend.name, - params=set_params, - callback=set_callback, - help="Set configuration options", - ) - config_set.add_command(set_cmd) - - @click.command(name=self.backend.name) - @click.pass_context - def config_options_purestorage(ctx): - self.backend.display_config_options() - - - config_options.add_command(config_options_purestorage) diff --git a/sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/main.tf b/sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/main.tf deleted file mode 100644 index 7913939ef..000000000 --- a/sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/main.tf +++ /dev/null @@ -1,56 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -terraform { - required_providers { - juju = { - source = "juju/juju" - version = "= 0.20.0" - } - } -} - -provider "juju" {} - -data "juju_model" "machine_model" { - name = var.machine_model -} - -# Deploy Pure Storage backend charms -resource "juju_application" "purestorage_backends" { - for_each = var.purestorage_backends - - name = each.key - model = data.juju_model.machine_model.name - units = 1 - - charm { - name = var.charm_purestorage_name - channel = var.charm_purestorage_channel - revision = var.charm_purestorage_revision - base = var.charm_purestorage_base - } - - config = merge({ - volume-backend-name = each.key - }, each.value.charm_config) - - endpoint_bindings = var.endpoint_bindings -} - -# Integrate Pure Storage backends with main cinder-volume -resource "juju_integration" "purestorage_to_cinder_volume" { - for_each = var.purestorage_backends - - model = var.machine_model - - application { - name = juju_application.purestorage_backends[each.key].name - endpoint = var.charm_purestorage_endpoint - } - - application { - name = "cinder-volume" - endpoint = var.charm_purestorage_endpoint - } -} diff --git a/sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/outputs.tf b/sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/outputs.tf deleted file mode 100644 index e648676da..000000000 --- a/sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/outputs.tf +++ /dev/null @@ -1,13 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -output "purestorage_backend_applications" { - description = "Map of deployed Pure Storage backend applications" - value = { - for name, app in juju_application.purestorage_backends : name => { - name = app.name - model = app.model - units = app.units - } - } -} diff --git a/sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/variables.tf b/sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/variables.tf deleted file mode 100644 index 6c6bacf6f..000000000 --- a/sunbeam-python/sunbeam/storage/backends/purestorage/deploy-purestorage-backend/variables.tf +++ /dev/null @@ -1,58 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -variable "machine_model" { - description = "Name of the machine model to deploy to" - type = string -} - -variable "charm_purestorage_name" { - description = "Name of the Pure Storage charm" - type = string - default = "cinder-volume-purestorage" -} - -variable "charm_purestorage_base" { - description = "Base for the Pure Storage charm" - type = string - default = "ubuntu@24.04" -} - -variable "charm_purestorage_channel" { - description = "Operator channel for Pure Storage backend deployment" - type = string - default = "latest/edge" -} - -variable "charm_purestorage_endpoint" { - description = "Endpoint name for Pure Storage backend integration" - type = string - default = "cinder-volume" -} - -variable "charm_purestorage_revision" { - description = "Operator channel revision for Pure Storage backend deployment" - type = number - default = null -} - -variable "purestorage_backends" { - description = "Map of Pure Storage backend configurations" - type = map(object({ - charm_config = map(string) - })) - default = {} -} - -variable "machine_ids" { - description = "List of machine ids to include" - type = list(string) - default = [] -} - -variable "endpoint_bindings" { - description = "Endpoint bindings for the applications" - type = set(map(string)) - default = null -} - diff --git a/sunbeam-python/sunbeam/storage/base.py b/sunbeam-python/sunbeam/storage/base.py index 7e94b7dbc..c1367e11e 100644 --- a/sunbeam-python/sunbeam/storage/base.py +++ b/sunbeam-python/sunbeam/storage/base.py @@ -3,31 +3,39 @@ """Storage backend base class with integrated Terraform functionality.""" +import enum import ipaddress import logging import re -from abc import ABC, abstractmethod +import types +import typing from pathlib import Path -from typing import Any, Dict, List, Optional, Set +from typing import Any import click +import pydantic from packaging.version import Version from rich.console import Console from rich.table import Table -from sunbeam.core.common import BaseStep, run_plan -from sunbeam.core.deployment import Deployment +from sunbeam import utils +from sunbeam.clusterd.client import Client +from sunbeam.core.common import BaseStep, RiskLevel, run_plan +from sunbeam.core.deployment import Deployment, Networks from sunbeam.core.juju import JujuHelper -from sunbeam.core.manifest import Manifest +from sunbeam.core.manifest import Manifest, StorageBackendConfig from sunbeam.core.terraform import TerraformHelper, TerraformInitStep - -from .models import ( +from sunbeam.storage.cli_base import StorageBackendCLIBase +from sunbeam.storage.models import ( BackendAlreadyExistsException, - BackendNotFoundException, - StorageBackendConfig, + SecretDictField, +) +from sunbeam.storage.service import StorageBackendService +from sunbeam.storage.steps import ( + BaseStorageBackendDeployStep, + BaseStorageBackendDestroyStep, + ValidateStoragePrerequisitesStep, ) -from .service import StorageBackendService -from .steps import ValidateStoragePrerequisitesStep LOG = logging.getLogger(__name__) console = Console() @@ -78,27 +86,26 @@ def validate_juju_application_name(name: str) -> bool: return True -class StorageBackendBase(ABC): +BackendConfig = typing.TypeVar("BackendConfig", bound=StorageBackendConfig) + + +class StorageBackendBase(typing.Generic[BackendConfig]): """Base class for storage backends with integrated Terraform functionality.""" - name: str = "base" + backend_type: str = "base" display_name: str = "Base Storage Backend" version = Version("0.0.1") - tf_plan_location = "FEATURE_REPO" # Plans stored in feature directory user_manifest = None # Path to user manifest file + # By default, any storage backend is considered beta risk. + # It will be needed to override in subclasses if the backend is + # considered stable. + risk_availability: RiskLevel = RiskLevel.BETA - def __init__(self): + def __init__(self) -> None: """Initialize storage backend.""" self.tfplan = "storage-backend-plan" self.tfplan_dir = "deploy-storage-backend" - self._manifest: Optional[Manifest] = None - self.service: Optional[StorageBackendService] = None - - def _get_service(self, deployment: Deployment) -> StorageBackendService: - """Get or create the storage backend service.""" - if self.service is None: - self.service = StorageBackendService(deployment) - return self.service + self._manifest: Manifest | None = None # Common CLI registration pattern (Abstraction 3: CLI registration) def register_add_cli(self, add: click.Group) -> None: # noqa: F811 @@ -111,22 +118,15 @@ def register_add_cli(self, add: click.Group) -> None: # noqa: F811 cli = cli_class(self) cli.register_add_cli(add) - def register_cli( # noqa: F811 - self, - remove: click.Group, - config_show: click.Group, - config_set: click.Group, - config_options: click.Group, - deployment: Deployment, - ) -> None: - """Register management commands for this backend. + def register_options_cli(self, options: click.Group) -> None: + """Register 'sunbeam storage options ' command. Default implementation delegates to CLI class following the pattern. Subclasses can override if they need custom behavior. """ cli_class = self._get_cli_class() cli = cli_class(self) - cli.register_cli(remove, config_show, config_set, config_options, deployment) + cli.register_options_cli(options) # Terraform-related properties and methods @property @@ -144,25 +144,38 @@ def manifest(self) -> Manifest: @property def tfvar_config_key(self) -> str: """Config key for storing Terraform variables in clusterd.""" - return f"TerraformVarsStorageBackends{self.name.title()}" + return "TerraformVarsStorageBackends" + + def config_key(self, name: str) -> str: + """Config key for a specific backend instance.""" + return f"Storage-{name}" - # Abstract methods that each backend must implement - @abstractmethod def create_deploy_step( self, deployment: Deployment, - client, + client: Client, tfhelper: TerraformHelper, jhelper: JujuHelper, manifest: Manifest, + preseed: dict, backend_name: str, - backend_config: StorageBackendConfig, model: str, + accept_defaults: bool = False, ) -> BaseStep: """Create a deployment step for this backend.""" - pass + return BaseStorageBackendDeployStep( + deployment, + client, + tfhelper, + jhelper, + manifest, + preseed, + backend_name, + self, + model, + accept_defaults, + ) - @abstractmethod def create_destroy_step( self, deployment: Deployment, @@ -174,17 +187,16 @@ def create_destroy_step( model: str, ) -> BaseStep: """Create a destruction step for this backend.""" - pass - - @abstractmethod - def create_update_config_step( - self, - deployment: Deployment, - backend_name: str, - config_updates: Dict[str, Any], - ) -> BaseStep: - """Create a configuration update step for this backend.""" - pass + return BaseStorageBackendDestroyStep( + deployment, + client, + tfhelper, + jhelper, + manifest, + backend_name, + self, + model, + ) def register_terraform_plan(self, deployment: Deployment) -> None: """Register storage backend Terraform plan with deployment system.""" @@ -194,7 +206,8 @@ def register_terraform_plan(self, deployment: Deployment) -> None: # Get the plan source path backend_self_contained = ( - Path(__file__).parent / "backends" / self.name / self.tfplan_dir + Path(__file__).parent.parent.parent.parent.parent.parent + / "etc/deploy-storage" # / "backends" / self.name / self.tfplan_dir ) if backend_self_contained.exists(): @@ -225,30 +238,31 @@ def register_terraform_plan(self, deployment: Deployment) -> None: # Register the helper with the deployment's tfhelpers deployment._tfhelpers[self.tfplan] = tfhelper - def add_backend( + def add_backend_instance( self, deployment: Deployment, - backend_name: str, - config: StorageBackendConfig, + name: str, + config: dict, console: Console, + accept_defaults: bool = False, ) -> None: """Add a storage backend using Terraform deployment.""" # Validate backend name follows Juju application naming rules - if not validate_juju_application_name(backend_name): + if not validate_juju_application_name(name): raise click.ClickException( - f"Invalid backend name '{backend_name}'. " - f"Backend names must be valid Juju application names: " - f"start with a letter, contain only lowercase letters, numbers," - f"and hyphens, cannot end with hyphen, cannot" - f"have consecutive hyphens, and cannot have numbers" - f"after the final hyphen." + f"Invalid backend name '{name}'. " + "Backend names must be valid Juju application names: " + "start with a letter, contain only lowercase letters, numbers," + "and hyphens, cannot end with hyphen, cannot" + "have consecutive hyphens, and cannot have numbers" + "after the final hyphen." ) - service = self._get_service(deployment) - if service.backend_exists(backend_name, self.name): - raise BackendAlreadyExistsException( - f"Backend '{backend_name}' already exists" - ) + service = StorageBackendService( + deployment, JujuHelper(deployment.juju_controller) + ) + if service.backend_exists(name, self.backend_type): + raise BackendAlreadyExistsException(f"Backend {name!r} already exists") # Register our Terraform plan with the deployment system self.register_terraform_plan(deployment) @@ -267,71 +281,99 @@ def add_backend( tfhelper, jhelper, self.manifest, - backend_name, config, + name, deployment.openstack_machines_model, + accept_defaults, ), ] run_plan(plan, console) - def _get_field_descriptions(self, config_class) -> dict: + def _get_field_descriptions(self, config_class: type[BackendConfig]) -> dict: """Extract field descriptions from a Pydantic v2 model class.""" - desc: Dict[str, str] = {} - if hasattr(config_class, "model_fields"): - for field_name, field_info in config_class.model_fields.items(): - desc[field_name] = getattr( - getattr(field_info, "field_info", field_info), - "description", - "No description available", - ) + desc: dict[str, str] = {} + for field_name, field_info in config_class.model_fields.items(): + desc[field_name] = field_info.description or "No description available" return desc - def _format_config_value(self, key: str, value) -> str: + def _field_is_secret(self, finfo) -> bool: + """Check if a field is marked as a secret.""" + for constraint in finfo.metadata: + if isinstance(constraint, SecretDictField): + return True + return False + + def _format_config_value(self, value, is_secret: bool) -> str: """Format configuration value for display, masking sensitive data.""" display_value = str(value) - if any(s in key.lower() for s in ["password", "secret", "token", "key"]): + if is_secret: display_value = "*" * min(8, len(display_value)) if display_value else "" if len(display_value) > 23: display_value = display_value[:20] + "..." return display_value - def _extract_field_info(self, field_info) -> tuple: + def _format_type(self, annotation: type) -> str: + """Return a consistent, human-readable representation of a type annotation.""" + origin = typing.get_origin(annotation) + args = typing.get_args(annotation) + + # Handle Optional / Union[..., None] + if origin is typing.Union and type(None) in args: + non_none_args = [a for a in args if a is not type(None)] + inner = " | ".join(self._format_type(a) for a in non_none_args) + return inner + + # Handle other Unions + if origin in (typing.Union, types.UnionType): + if args and args[-1] is type(None): + args = args[:-1] + return " | ".join(self._format_type(a) for a in args) + + # Handle Literal types + if origin is typing.Literal: + values = ", ".join(repr(a) for a in args) + return f"{values}" + + # Handle Enum subclasses + if isinstance(annotation, type) and issubclass(annotation, enum.Enum): + # Display allowed values (stringified) + options = ", ".join(repr(e.value) for e in annotation) + return f"{options}" + + # Handle parametrised generics, e.g., list[str], dict[str, int] + if origin is not None: + origin_name = getattr(origin, "__name__", str(origin)) + inner = ", ".join(self._format_type(a) for a in args) + return f"{origin_name}[{inner}]" if args else origin_name + + # Handle bare types like int, bool, str, MyClass + if hasattr(annotation, "__name__"): + return annotation.__name__ + + # Handle special typing constructs (e.g. Any) + return str(annotation) + + def _extract_field_info(self, field_info: pydantic.fields.FieldInfo) -> tuple: """Extract field type, default value, and description from field info.""" - if hasattr(field_info, "type_"): - field_type = str(field_info.type_).replace("", "") - elif hasattr(field_info, "annotation"): - field_type = ( - str(field_info.annotation).replace("", "") - ) + if field_info.annotation: + field_type = self._format_type(field_info.annotation) else: field_type = "str" - if hasattr(field_info, "default"): - default_value = ( - str(field_info.default) if field_info.default is not ... else "Required" - ) - else: - default_value = "Unknown" - - if hasattr(field_info, "field_info") and hasattr( - field_info.field_info, "description" - ): - description = field_info.field_info.description or "No description" - elif hasattr(field_info, "description"): - description = field_info.description or "No description" - else: - description = "No description" + if field_info.is_required(): + field_type += " [red]Required[/red]" + + description = field_info.description or "No description" - return field_type, default_value, description + return field_type, description def display_config_options(self) -> None: """Display available configuration options for this backend.""" console.print( f"[blue]Available configuration options for {self.display_name}:[/blue]" ) - config_class = self.config_class - fields = getattr(config_class, "model_fields", {}) + fields = self.config_type().model_fields if not fields: console.print( " Configuration options are managed dynamically via Terraform." @@ -344,21 +386,20 @@ def display_config_options(self) -> None: table = Table(show_header=True, header_style="bold blue") table.add_column("Option", style="cyan") table.add_column("Type", style="green") - table.add_column("Default", style="yellow") table.add_column("Description", style="white") for field_name, finfo in fields.items(): if field_name == "name": continue try: - ftype, default, descr = self._extract_field_info(finfo) - table.add_row(field_name, ftype, default, descr) + ftype, descr = self._extract_field_info(finfo) + table.add_row(field_name, ftype, descr) except Exception: - table.add_row(field_name, "str", "Unknown", "Configuration option") + table.add_row(field_name, "str", "Configuration option") console.print(table) - def display_config_table(self, backend_name: str, config: dict) -> None: + def display_config_table(self, backend_name: str, config: BackendConfig) -> None: """Display current configuration in a formatted table for this backend.""" table = Table( title=f"Configuration for {self.display_name} backend '{backend_name}'", @@ -372,8 +413,11 @@ def display_config_table(self, backend_name: str, config: dict) -> None: table.add_column("Value", style="green", width=25) table.add_column("Description", style="dim", width=50) - field_descriptions = self._get_field_descriptions(self.config_class) - for key, value in sorted(config.items()): + field_descriptions = self._get_field_descriptions(self.config_type()) + for field, finfo in self.config_type().model_fields.items(): + value = getattr(config, field, None) + if not value: + continue # Skip empty values (None, empty string, empty dict, empty list) # But keep 0 and False as valid values if ( @@ -383,16 +427,18 @@ def display_config_table(self, backend_name: str, config: dict) -> None: ): continue - display_value = self._format_config_value(key, value) - description = field_descriptions.get(key, "Configuration option") + display_value = self._format_config_value( + value, is_secret=self._field_is_secret(finfo) + ) + description = field_descriptions.get(field, "Configuration option") if len(description) > 47: description = description[:44] + "..." - table.add_row(key, display_value, description) + table.add_row(utils.to_kebab(field), display_value, description) if not config: console.print( ( - f"[yellow]No configuration found for {self.name} " + f"[yellow]No configuration found for {self.backend_type} " f"backend '{backend_name}'[/yellow]" ) ) @@ -409,10 +455,6 @@ def remove_backend( self, deployment: Deployment, backend_name: str, console: Console ) -> None: """Remove a storage backend using Terraform.""" - service = self._get_service(deployment) - if not service.backend_exists(backend_name, self.name): - raise BackendNotFoundException(f"Backend '{backend_name}' not found") - # Register our Terraform plan with the deployment system self.register_terraform_plan(deployment) @@ -438,194 +480,119 @@ def remove_backend( run_plan(plan, console) - def update_backend_config( - self, deployment: Deployment, backend_name: str, config_updates: Dict[str, Any] - ) -> None: - """Update backend configuration using Terraform.""" - service = self._get_service(deployment) - if not service.backend_exists(backend_name, self.name): - raise BackendNotFoundException(f"Backend '{backend_name}' not found") - - # Ensure the Terraform plan is registered so we can obtain its tfhelper - self.register_terraform_plan(deployment) - - plan = [ - TerraformInitStep(deployment.get_tfhelper(self.tfplan)), - self.create_update_config_step(deployment, backend_name, config_updates), - ] - - run_plan(plan, console) - - @property - def config_class(self) -> type[StorageBackendConfig]: + def config_type(self) -> type[BackendConfig]: """Return the configuration class for this backend.""" - return StorageBackendConfig + raise NotImplementedError("Subclasses must implement config_type") # Backend-specific properties that subclasses should override - @property - def backend_type(self) -> str: - """Backend type identifier. Override in subclasses.""" - return self.name - @property def charm_name(self) -> str: - """Charm name for this backend. Override in subclasses.""" + """Charm name for this backend.""" raise NotImplementedError("Subclasses must define charm_name") @property def charm_channel(self) -> str: - """Charm channel for this backend. Override in subclasses.""" - return "stable" + """Charm channel for this backend.""" + return "latest/stable" @property - def charm_revision(self) -> Optional[int]: - """Charm revision for this backend. Override in subclasses.""" + def charm_revision(self) -> str | None: + """Charm revision for this backend.""" return None @property def charm_base(self) -> str: - """Charm base for this backend. Override in subclasses.""" + """Charm base for this backend.""" return "ubuntu@22.04" @property def backend_endpoint(self) -> str: - """Backend endpoint name for integration. Override in subclasses.""" + """Backend endpoint name for integration.""" return "cinder-volume" @property def units(self) -> int: - """Number of units to deploy. Override in subclasses.""" + """Number of units to deploy.""" return 1 @property - def additional_integrations(self) -> List[str]: - """Additional integrations for this backend. Override in subclasses.""" + def additional_integrations(self) -> list[str]: + """Additional integrations for this backend.""" return [] - @abstractmethod - def get_terraform_variables( - self, backend_name: str, config: StorageBackendConfig, model: str - ) -> Dict[str, Any]: - """Generate Terraform variables for this backend. Override in subclasses.""" - raise NotImplementedError("Subclasses must implement get_terraform_variables") - - def get_field_mapping(self) -> Dict[str, str]: - """Get mapping from config fields to charm config options. + @property + def principal_application(self) -> str: + """Principal application for this backend. - Maps Pydantic field names (with underscores) to charm config option - names (with hyphens). Uses the config_class to automatically generate - the mapping from Pydantic model fields. + To override when supporting non-ha backends. """ - config_class = self.config_class - # Use model_fields for Pydantic v2 - model_fields = getattr(config_class, "model_fields", {}) - field_names = model_fields.keys() if model_fields else [] - - return {key: key.replace("_", "-") for key in field_names} + return "cinder-volume" - @abstractmethod - def prompt_for_config(self, backend_name: str) -> StorageBackendConfig: - """Prompt user for backend-specific configuration. Override in subclasses.""" - raise NotImplementedError("Subclasses must implement prompt_for_config") + def get_endpoint_bindings(self, deployment: Deployment) -> list[dict[str, str]]: + """Endpoint bindings for this backend.""" + return [ + {"space": deployment.get_space(Networks.MANAGEMENT)}, + { + "endpoint": "cinder-volume", + "space": deployment.get_space(Networks.STORAGE), + }, + ] - # Common utility methods - def _filter_config_for_charm( + def build_terraform_vars( self, - config_dict: Dict[str, Any], - field_mapping: Dict[str, str], - exclude_fields: Set[str] | None = None, - ) -> Dict[str, Any]: - """Filter configuration dictionary for charm deployment. - - Only includes explicitly set values (non-default, non-empty) to avoid - sending unnecessary configuration to the charm. - - Args: - config_dict: Configuration dictionary to filter - field_mapping: Mapping from config keys to charm config keys - exclude_fields: Set of fields to exclude from filtering - - Returns: - Filtered charm configuration dictionary - """ - exclude_fields = exclude_fields or set() - - # Get default values for comparison - default_config = self._get_default_config() - default_dict = default_config.model_dump() - - charm_config = {} - for key, value in config_dict.items(): - # Skip excluded fields - if key in exclude_fields: - continue - - if key in field_mapping: - # Only include explicitly set values (non-default, non-empty) - if self._should_include_config_value(key, value, default_dict.get(key)): - charm_config[field_mapping[key]] = value - - return charm_config - - def _should_include_config_value( - self, key: str, value: Any, default_value: Any - ) -> bool: - """Determine if a configuration value should be included in charm config. - - Args: - key: Configuration field name - value: Current value - default_value: Default value for this field - - Returns: - True if the value should be sent to the charm, False otherwise - """ - # Always include the 'name' field as it's required - if key == "name": - return True - - # Skip None values - if value is None: - return False - - # Skip empty strings - if isinstance(value, str) and value.strip() == "": - return False - - # Skip empty lists - if isinstance(value, list) and len(value) == 0: - return False - - # Skip empty dictionaries - if isinstance(value, dict) and len(value) == 0: - return False - - # Skip values that match the default - if value == default_value: - return False - - # Include all other values - return True - - @abstractmethod - def _get_default_config(self) -> StorageBackendConfig: - """Get a default configuration instance for comparison. - - Subclasses must implement this to provide a default config instance - with dummy values for required fields. - """ - raise NotImplementedError("Subclasses must implement _get_default_config") - - def _get_credential_fields(self) -> Set[str]: - """Get set of credential field names that should be excluded from charm config. - - Subclasses can override this to specify which fields contain credentials - that should not be sent to the charm configuration. - - Returns: - Set of field names containing credentials - """ - return {"name"} # Default: only exclude the name field + deployment: Deployment, + manifest: Manifest, + backend_name: str, + config: BackendConfig, + ) -> dict[str, Any]: + """Generate Terraform variables for Pure Storage backend deployment.""" + # Map our configuration fields to the correct charm configuration option names + config_dict = config.model_dump(exclude_none=True, by_alias=True) + + # Secret fields that will be translated to juju secrets + # K: config field name, V: field key in juju secret + secret_fields = {} + alias_generator = self.config_type().model_config.get("alias_generator") + if alias_generator is None: + raise RuntimeError( + "Alias generator not defined in config model StorageBackendConfig" + ) + # raise if alias generator is callable + if not hasattr(alias_generator, "generate_aliases"): + raise RuntimeError( + "Alias generator is not of type AliasGenerator in" + " config model StorageBackendConfig" + ) + for fname, finfo in self.config_type().model_fields.items(): + for constraint in finfo.metadata: + if isinstance(constraint, SecretDictField): + secret_fields[alias_generator.generate_aliases(fname)[2]] = ( # type: ignore + constraint.field + ) + + charm_channel = self.charm_channel + charm_revision = None + if backends_cfg := manifest.storage.root.get(self.backend_type): + if backend_cfg := backends_cfg.root.get(backend_name): + if charm_cfg := backend_cfg.software.charms.get(self.charm_name): + if channel := charm_cfg.channel: + charm_channel = channel + if revision := charm_cfg.revision: + charm_revision = revision + + # Build Terraform variables to match the plan's expected format + tfvars = { + "principal_application": self.principal_application, + "charm_name": self.charm_name, + "charm_base": self.charm_base, + "charm_channel": charm_channel, + "charm_revision": charm_revision, + "endpoint_bindings": self.get_endpoint_bindings(deployment), + "charm_config": config_dict, + "secrets": secret_fields, + } + + return tfvars # Common utility methods (Abstraction 2: IP/FQDN validation) @staticmethod @@ -650,7 +617,7 @@ def _validate_ip_or_fqdn(value: str) -> str: return value raise click.BadParameter("Must be a valid IP address or FQDN") - def _get_cli_class(self): + def _get_cli_class(self) -> type[StorageBackendCLIBase]: """Get the CLI class for this backend. Subclasses should override this to return their CLI class. @@ -658,13 +625,12 @@ def _get_cli_class(self): """ try: # Try to import CLI class based on naming convention - module_path = f"sunbeam.storage.backends.{self.name}.cli" - cli_module = __import__(module_path, fromlist=[f"{self.name.title()}CLI"]) - cli_class_name = f"{self.name.title()}CLI" + module_path = f"sunbeam.storage.backends.{self.backend_type}.cli" + cli_module = __import__( + module_path, fromlist=[f"{self.backend_type.title()}CLI"] + ) + cli_class_name = f"{self.backend_type.title()}CLI" return getattr(cli_module, cli_class_name) except (ImportError, AttributeError): - raise NotImplementedError( - f"Subclasses must implement _get_cli_class or " - f"follow naming convention: " - f"sunbeam.storage.backends.{self.name}.cli.{self.name.title()}CLI" - ) + LOG.debug(f"{self.backend_type} does not implement custom cli class") + return StorageBackendCLIBase diff --git a/sunbeam-python/sunbeam/storage/cli_base.py b/sunbeam-python/sunbeam/storage/cli_base.py new file mode 100644 index 000000000..af8c76b07 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/cli_base.py @@ -0,0 +1,259 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Base CLI functionality for storage backends. + +This module contains the base CLI class that provides common functionality +for all storage backend CLI implementations. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import click +import yaml +from rich.console import Console + +from sunbeam.core.deployment import Deployment + +if TYPE_CHECKING: + from sunbeam.storage.base import StorageBackendBase + + +console = Console() + + +class StorageBackendCLIBase: + """Base CLI functionality for storage backends. + + This class provides common CLI operations for storage backends including: + - Loading configuration files (YAML) + - Building Click parameters from Pydantic models + - Registering add/remove/config commands + - Handling interactive and non-interactive modes + """ + + backend: StorageBackendBase + + def __init__(self, backend: StorageBackendBase): + """Initialize CLI with a backend instance. + + Args: + backend: The storage backend instance (must have config_class attribute) + """ + self.backend = backend + + def _load_config_file(self, path: Path | None = None) -> dict[str, Any]: + """Load YAML config file into a dictionary. + + Args: + path: Path to the configuration file + + Returns: + Dictionary containing the configuration + """ + if not path: + return {} + text = path.read_text() + return dict(yaml.safe_load(text) or {}) + + def _click_type_for(self, field_info) -> click.types.ParamType: + """Map pydantic field to Click type. + + Args: + field_info: Pydantic field info object + + Returns: + Appropriate Click parameter type + """ + ann = getattr(field_info, "annotation", None) + typ = None + if ann is not None: + typ = str(ann) + elif hasattr(field_info, "type_"): + typ = str(field_info.type_) + if typ and ("int" in typ): + return click.INT + if typ and ("float" in typ): + return click.FLOAT + if typ and ("bool" in typ): + return click.BOOL + return click.STRING + + def _build_add_params(self) -> list: + """Build Click parameters for the add command from config model.""" + params: list = [] + # name option (not required; prompt in interactive mode) + params.append(click.Argument(["name"], type=str, required=True)) + # config file (optional) + params.append( + click.Option( + ["--config-file"], + type=click.Path(exists=True, dir_okay=False, path_type=Path), + required=False, + help="YAML config file", + ) + ) + params.append( + click.Option( + ["--accept-defaults", "-a"], + is_flag=True, + required=False, + help="In interactive mode, accept default values where available", + ) + ) + # Model-derived options + fields = self.backend.config_type().model_fields + for fname, finfo in fields.items(): + if fname == "name": + continue + opt_name = "--" + fname.replace("_", "-") + click_type = self._click_type_for(finfo) + # For interactive UX, keep CLI options optional; the model + # enforces requiredness. + is_required = False + # Help text + descr = finfo.description + params.append( + click.Option( + [opt_name], type=click_type, required=is_required, help=descr + ) + ) + return params + + def _build_config_from_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]: + """Extract config values from Click kwargs. + + Click converts dashes to underscores in parameter names. + + Args: + kwargs: Keyword arguments from Click command + + Returns: + Dictionary with non-None configuration values + """ + cfg: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + cfg[k] = v + return cfg + + def _build_set_params(self) -> list: + """Build Click parameters for config set command. + + All parameters are optional since we only update provided values. + + Returns: + List of Click Option objects + """ + params = [] + # Add config-file option first + params.append( + click.Option( + ["--config-file"], + type=click.Path( + exists=True, dir_okay=False, readable=True, path_type=Path + ), + required=False, + help="YAML config file with updates", + ) + ) + fields = self.backend.config_type().model_fields + for fname, finfo in fields.items(): + if fname == "name": + continue + opt = "--" + fname.replace("_", "-") + # For updates, make everything optional with default None + # so we can detect presence + click_type: click.ParamType = click.STRING + ann = finfo.annotation + if ann is not None and ("bool" in str(ann)): + click_type = click.BOOL + elif ann is not None and ("int" in str(ann)): + click_type = click.INT + elif ann is not None and ("float" in str(ann)): + click_type = click.FLOAT + params.append( + click.Option( + [opt], + type=click_type, + required=False, + default=None, + help=finfo.description, + ) + ) + return params + + def register_add_cli(self, add: click.Group) -> None: # noqa: C901 + """Register 'sunbeam storage add ' command. + + Includes typed options and a --config-file flag. + Supports both interactive and non-interactive modes. + + Args: + add: Click group to add the command to + """ + + def add_callback(**kwargs): + deployment: Deployment = click.get_current_context().obj + cfg_file = kwargs.pop("config_file", None) + accept_defaults = kwargs.pop("accept_defaults", False) + file_cfg = self._load_config_file(cfg_file) + cli_cfg = self._build_config_from_kwargs(kwargs) + # Determine if interactive: no config-file and no CLI options supplied + provided_cli_values = {k: v for k, v in cli_cfg.items() if v is not None} + # Name is guaranted to be given through the CLI. + backend_name = provided_cli_values.pop("name") + + merged = {**file_cfg, **provided_cli_values} + self.backend.add_backend_instance( + deployment, backend_name, merged, console, accept_defaults + ) + + # Build command dynamically with parameters + params = self._build_add_params() + help_text = ( + f"Add {self.backend.display_name} backend.\n\n" + "Behavior:\n\n" + "- If no options are provided, runs in interactive mode and prompts " + "for all required fields.\n\n" + "- If options and/or --config-file are provided, runs non-" + "interactively and validates against the model.\n\n" + "- In non-interactive mode.\n\n" + "Examples:\n\n" + f" sunbeam storage add {self.backend.backend_type} my-backend\n\n" + f" sunbeam storage add {self.backend.backend_type} my-backend " + f"--config-file {self.backend.backend_type}.yaml\n" + ) + cmd = click.Command( + name=self.backend.backend_type, + params=params, + callback=add_callback, + help=help_text, + ) + add.add_command(cmd) + + def register_options_cli(self, options: click.Group) -> None: + """Register 'sunbeam storage options ' command. + + Args: + options: Click group to add the command to + """ + + def options_callback(**kwargs): + self.backend.display_config_options() + + help_text = ( + f"Show configuration options for all {self.backend.display_name} backends." + "\n\n" + "Displays the current configuration values for each backend instance.\n" + ) + cmd = click.Command( + name=self.backend.backend_type, + callback=options_callback, + help=help_text, + ) + options.add_command(cmd) diff --git a/sunbeam-python/sunbeam/storage/manager.py b/sunbeam-python/sunbeam/storage/manager.py new file mode 100644 index 000000000..776c4f405 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/manager.py @@ -0,0 +1,298 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +import importlib +import logging +import pathlib +import typing +from typing import Dict + +import click +from rich.console import Console +from rich.table import Table +from snaphelpers import Snap + +from sunbeam.core.common import infer_risk +from sunbeam.core.deployment import Deployment +from sunbeam.core.juju import JujuHelper +from sunbeam.core.manifest import StorageInstanceManifest +from sunbeam.storage.base import StorageBackendBase +from sunbeam.storage.models import BackendNotFoundException, StorageBackendInfo +from sunbeam.storage.service import StorageBackendService + +LOG = logging.getLogger(__name__) +console = Console() + +# Global registry for storage backends +_STORAGE_BACKENDS: Dict[str, StorageBackendBase] = {} + + +@click.group("storage", context_settings={"help_option_names": ["-h", "--help"]}) +@click.pass_context +def storage(ctx): + """Manage Cinder storage backends. + + Provides commands to add, remove, configure and list storage backends. + Supports multiple backend types including Hitachi VSP and others. + """ + # Ensure we have a deployment object + if not hasattr(ctx, "obj") or not isinstance(ctx.obj, Deployment): + raise click.ClickException( + "Storage commands require a valid deployment context. " + "Please ensure sunbeam is properly initialized." + ) + + +class StorageBackendManager: + """Registry for managing storage backends.""" + + _backends: dict[str, StorageBackendBase] = _STORAGE_BACKENDS + _loaded: bool = False + + def __init__(self) -> None: + if not self._backends: + self._load_backends() + + def _load_backends(self) -> None: + """Load all storage backends from the storage/backends directory.""" + if self._loaded: + return + + LOG.debug("Loading storage backends") + import sunbeam.storage.backends + + sunbeam_storage_backends = pathlib.Path( + sunbeam.storage.backends.__file__ + ).parent + + for path in sunbeam_storage_backends.iterdir(): + # Skip non-directories and special files + if not path.is_dir() or path.name.startswith("_") or path.name == "etc": + continue + + backend_name = path.name + backend_module_path = path / "backend.py" + + # Check if the backend.py file exists in the backend directory + if not backend_module_path.exists(): + LOG.debug(f"Skipping {backend_name}: no backend.py file found") + continue + + try: + LOG.debug(f"Loading storage backend: {backend_name}") + # Import the backend module from the backend subdirectory + mod = importlib.import_module( + f"sunbeam.storage.backends.{backend_name}.backend" + ) + + # Look for backend classes + for attr_name in dir(mod): + attr = getattr(mod, attr_name) + if ( + isinstance(attr, type) + and issubclass(attr, StorageBackendBase) + and attr != StorageBackendBase + ): + backend_instance = attr() + self._backends[backend_instance.backend_type] = backend_instance + LOG.debug( + "Registered storage backend: " + + backend_instance.backend_type + ) + + except Exception as e: + LOG.debug("Failed to load storage backend", exc_info=True) + LOG.warning(f"Failed to load storage backend {backend_name}: {e}") + + self._loaded = True + + def get_backend(self, name: str) -> StorageBackendBase: + """Get a storage backend by name.""" + self._load_backends() + if name not in self._backends: + raise ValueError(f"Storage backend '{name}' not found") + return self._backends[name] + + def backends(self) -> typing.Mapping[str, StorageBackendBase]: + """Get all available storage backends.""" + return self._backends + + def get_all_storage_manifests( + self, + ) -> dict[str, dict[str, StorageInstanceManifest]]: + """Return a dict of all feature manifest defaults.""" + manifests: dict[str, dict[str, StorageInstanceManifest]] = {} + for name in self.backends(): + manifests[name] = {} + + return manifests + + def register(self, cli: click.Group, deployment: Deployment) -> None: + """Register storage backend commands with the storage group. + + This function is called from main.py to register all storage backend + commands dynamically based on available backends. + """ + cli.add_command(storage) + try: + self.register_cli_commands(storage, deployment) + LOG.debug("Storage backend commands registered successfully") + except Exception as e: + LOG.error(f"Failed to register storage backend commands: {e}") + raise e + + def register_cli_commands( + self, storage_group: click.Group, deployment: Deployment + ) -> None: + """Register all backend commands with the storage CLI group. + + This now follows the provider pattern: create stable top-level groups + and let each backend self-register its subcommands under those groups. + The CLI UX remains the same, e.g.: + sunbeam storage add [...] + sunbeam storage remove + sunbeam storage list all + sunbeam storage config show + sunbeam storage config set key=value ... + sunbeam storage config reset key ... + sunbeam storage config options [name] + """ + self._load_backends() + + # Top-level subgroups + @click.group(name="add") + def add_group(): + """Add a storage backend.""" + pass + + @click.group(name="options") + def options_group(): + """Show storage backend configuration options.""" + pass + + @click.command(name="list") + @click.pass_context + def list_all(ctx): + """List all storage backends.""" + jhelper = JujuHelper(deployment.juju_controller) + service = StorageBackendService(deployment, jhelper) + backends = service.list_backends() + self._display_backends_table(backends) + + @click.command(name="remove") + @click.argument("backend_name", type=str) + @click.option("--force", is_flag=True, help="Skip confirmation prompt") + @click.pass_context + def remove_backend(ctx, backend_name: str, force: bool): + """Remove a storage backend.""" + service = StorageBackendService( + deployment, JujuHelper(deployment.juju_controller) + ) + try: + storage_backend = service.get_backend(backend_name) + except BackendNotFoundException: + console.print( + f"[red]Error: Storage backend {backend_name!r} not found.[/red]" + ) + raise click.Abort() + backend = self.backends().get(storage_backend.type) + if not backend: + console.print( + f"[red]Error: Storage backend type " + f"{storage_backend.type!r} not recognized.[/red]" + ) + raise click.Abort() + if not force: + click.confirm( + f"Remove {backend.display_name} backend {backend_name!r}?", + abort=True, + ) + try: + backend.remove_backend(deployment, backend_name, console) + console.print( + f"Successfully removed {backend.display_name} " + f"backend {backend_name!r}" + ) + except Exception as e: + console.print(f"[red]Error removing backend: {e}[/red]") + raise click.Abort() + + @click.command(name="show") + @click.argument("backend_name", type=str) + @click.pass_context + def show_backend(ctx, backend_name: str): + """Show configuration for a storage backend.""" + service = StorageBackendService( + deployment, JujuHelper(deployment.juju_controller) + ) + try: + storage_backend = service.get_backend(backend_name) + except BackendNotFoundException: + console.print( + f"[red]Error: Storage backend {backend_name!r} not found.[/red]" + ) + raise click.Abort() + backend = self.backends().get(storage_backend.type) + if not backend: + console.print( + f"[red]Error: Storage backend type " + f"{storage_backend.type!r} not recognized.[/red]" + ) + raise click.Abort() + config = backend.config_type().model_validate( + storage_backend.config, by_alias=True + ) + backend.display_config_table(backend_name, config) + + installation_risk = infer_risk(Snap()) + + # Delegate CLI registration to each backend + for backend in self._backends.values(): + if backend.risk_availability > installation_risk: + LOG.debug( + "Not registering backend %r, " + "it is available at a higher risk level", + backend.backend_type, + ) + continue + try: + backend.register_add_cli(add_group) + backend.register_options_cli(options_group) + except Exception as e: + backend_name = getattr(backend, "name", "unknown") + LOG.warning( + "Backend %s failed to register CLI: %s", + backend_name, + e, + ) + raise + + # Mount groups under storage + storage_group.add_command(list_all) + storage_group.add_command(add_group) + storage_group.add_command(show_backend) + storage_group.add_command(options_group) + storage_group.add_command(remove_backend) + + def _display_backends_table(self, backends: list[StorageBackendInfo]) -> None: + """Display backends in a formatted table.""" + if not backends: + console.print("[yellow]No storage backends found[/yellow]") + return + + table = Table(title="Storage Backends") + table.add_column("Name", style="cyan") + table.add_column("Type", style="magenta") + table.add_column("Status", style="green") + table.add_column("Charm", style="blue") + + for backend in backends: + status_style = "green" if backend.status == "active" else "red" + table.add_row( + backend.name, + backend.backend_type, + f"[{status_style}]{backend.status}[/{status_style}]", + backend.charm, + ) + + console.print(table) diff --git a/sunbeam-python/sunbeam/storage/models.py b/sunbeam-python/sunbeam/storage/models.py index 9aa09f205..1dd8f8cc9 100644 --- a/sunbeam-python/sunbeam/storage/models.py +++ b/sunbeam-python/sunbeam/storage/models.py @@ -5,7 +5,7 @@ from typing import Any, Dict -from pydantic import BaseModel, Field +import pydantic from sunbeam.core.common import SunbeamException @@ -43,13 +43,7 @@ class BackendValidationException(StorageBackendException): # ============================================================================= -class StorageBackendConfig(BaseModel): - """Base configuration model for storage backends.""" - - name: str = Field(..., description="Backend name") - - -class StorageBackendInfo(BaseModel): +class StorageBackendInfo(pydantic.BaseModel): """Information about a deployed storage backend.""" name: str @@ -57,3 +51,20 @@ class StorageBackendInfo(BaseModel): status: str charm: str config: Dict[str, Any] = {} + + +class SecretDictField: + """Marker class to indicate a field needs to be managed as a juju secret. + + This class is used as a field annotation in Pydantic models to indicate that + the field contains sensitive information (e.g., passwords, API tokens). + + The field name is the name of the key in the Juju secret dictionary. + """ + + def __init__(self, field: str): + self.field = field + + def __repr__(self) -> str: + """Return a string representation of the SecretDictField.""" + return f"SecretDictField(field={self.field})" diff --git a/sunbeam-python/sunbeam/storage/registry.py b/sunbeam-python/sunbeam/storage/registry.py deleted file mode 100644 index 867259987..000000000 --- a/sunbeam-python/sunbeam/storage/registry.py +++ /dev/null @@ -1,192 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -import importlib -import logging -import pathlib -from typing import Dict, List - -import click -from rich.console import Console -from rich.table import Table - -from sunbeam.core.deployment import Deployment -from sunbeam.storage.base import StorageBackendBase -from sunbeam.storage.models import StorageBackendInfo -from sunbeam.storage.service import StorageBackendService - -LOG = logging.getLogger(__name__) -console = Console() - -# Global registry for storage backends -_STORAGE_BACKENDS: Dict[str, StorageBackendBase] = {} - - -class StorageBackendRegistry: - """Registry for managing storage backends.""" - - def __init__(self): - self._backends: Dict[str, StorageBackendBase] = {} - self._loaded = False - - def _load_backends(self) -> None: - """Load all storage backends from the storage/backends directory.""" - if self._loaded: - return - - LOG.debug("Loading storage backends") - import sunbeam.storage.backends - - sunbeam_storage_backends = pathlib.Path( - sunbeam.storage.backends.__file__ - ).parent - - for path in sunbeam_storage_backends.iterdir(): - # Skip non-directories and special files - if not path.is_dir() or path.name.startswith("_") or path.name == "etc": - continue - - backend_name = path.name - backend_module_path = path / "backend.py" - - # Check if the backend.py file exists in the backend directory - if not backend_module_path.exists(): - LOG.debug(f"Skipping {backend_name}: no backend.py file found") - continue - - try: - LOG.debug(f"Loading storage backend: {backend_name}") - # Import the backend module from the backend subdirectory - mod = importlib.import_module( - f"sunbeam.storage.backends.{backend_name}.backend" - ) - - # Look for backend classes - for attr_name in dir(mod): - attr = getattr(mod, attr_name) - if ( - isinstance(attr, type) - and issubclass(attr, StorageBackendBase) - and attr != StorageBackendBase - ): - backend_instance = attr() - self._backends[backend_instance.name] = backend_instance - LOG.debug( - f"Registered storage backend: {backend_instance.name}" - ) - - except Exception as e: - LOG.warning(f"Failed to load storage backend {backend_name}: {e}") - - self._loaded = True - - def get_backend(self, name: str) -> StorageBackendBase: - """Get a storage backend by name.""" - self._load_backends() - if name not in self._backends: - raise ValueError(f"Storage backend '{name}' not found") - return self._backends[name] - - def list_backends(self) -> Dict[str, StorageBackendBase]: - """Get all available storage backends.""" - self._load_backends() - return self._backends.copy() - - def get_backends(self) -> Dict[str, StorageBackendBase]: - """Get all available storage backends (alias for list_backends).""" - return self.list_backends() - - def register_cli_commands( - self, storage_group: click.Group, deployment: Deployment - ) -> None: - """Register all backend commands with the storage CLI group. - - This now follows the provider pattern: create stable top-level groups - and let each backend self-register its subcommands under those groups. - The CLI UX remains the same, e.g.: - sunbeam storage add [...] - sunbeam storage remove - sunbeam storage list all - sunbeam storage config show - sunbeam storage config set key=value ... - sunbeam storage config reset key ... - sunbeam storage config options [name] - """ - self._load_backends() - - # Top-level subgroups - add_group = click.Group(name="add") - remove_group = click.Group(name="remove") - list_group = click.Group(name="list") - config_group = click.Group(name="config") - - # Config subgroups for backend-specific commands - config_show = click.Group(name="show") - config_set = click.Group(name="set") - config_reset = click.Group(name="reset") - config_options = click.Group(name="options") - - # Attach config subgroups to the config group - config_group.add_command(config_show) - config_group.add_command(config_set) - config_group.add_command(config_reset) - config_group.add_command(config_options) - - # List commands (keep generic 'all') - @click.command(name="all") - @click.pass_context - def list_all(ctx): - """List all storage backends.""" - service = StorageBackendService(deployment) - backends = service.list_backends() - self._display_backends_table(backends) - - list_group.add_command(list_all) - - # Delegate CLI registration to each backend - for backend in self._backends.values(): - try: - backend.register_add_cli(add_group) - backend.register_cli( - remove_group, - config_show, - config_set, - config_options, - deployment, - ) - except Exception as e: - backend_name = getattr(backend, "name", "unknown") - LOG.warning( - "Backend %s failed to register CLI: %s", - backend_name, - e, - ) - - # Mount groups under storage - storage_group.add_command(add_group) - storage_group.add_command(remove_group) - storage_group.add_command(list_group) - storage_group.add_command(config_group) - - def _display_backends_table(self, backends: List[StorageBackendInfo]) -> None: - """Display backends in a formatted table.""" - if not backends: - console.print("[yellow]No storage backends found[/yellow]") - return - - table = Table(title="Storage Backends") - table.add_column("Name", style="cyan") - table.add_column("Type", style="magenta") - table.add_column("Status", style="green") - table.add_column("Charm", style="blue") - - for backend in backends: - status_style = "green" if backend.status == "active" else "red" - table.add_row( - backend.name, - backend.backend_type, - f"[{status_style}]{backend.status}[/{status_style}]", - backend.charm, - ) - - console.print(table) diff --git a/sunbeam-python/sunbeam/storage/service.py b/sunbeam-python/sunbeam/storage/service.py index 31e491158..938606e96 100644 --- a/sunbeam-python/sunbeam/storage/service.py +++ b/sunbeam-python/sunbeam/storage/service.py @@ -4,18 +4,18 @@ """Storage backend service layer.""" import logging -from typing import Any, Dict, List from rich.console import Console -from sunbeam.clusterd.service import ConfigItemNotFoundException -from sunbeam.core.common import read_config +from sunbeam.clusterd.models import StorageBackend +from sunbeam.clusterd.service import ( + StorageBackendNotFoundException, +) from sunbeam.core.deployment import Deployment from sunbeam.core.juju import JujuHelper - -from .models import ( +from sunbeam.storage.models import ( + BackendAlreadyExistsException, BackendNotFoundException, - StorageBackendException, StorageBackendInfo, ) @@ -26,20 +26,16 @@ class StorageBackendService: """Service layer for storage backend operations.""" - def __init__(self, deployment: Deployment): + def __init__(self, deployment: Deployment, jhelper: JujuHelper): self.deployment = deployment - self.model = self._get_model_name() + self.jhelper = jhelper + self.model = jhelper.get_model_name_with_owner( + self.deployment.openstack_machines_model + ) # Use a consistent config key for all storage backends self._tfvar_config_key = "TerraformVarsStorageBackends" - def _get_model_name(self) -> str: - """Get the OpenStack machines model name.""" - model = self.deployment.openstack_machines_model - if not model.startswith("admin/"): - model = f"admin/{model}" - return model - - def list_backends(self) -> List[StorageBackendInfo]: + def list_backends(self) -> list[StorageBackendInfo]: """List all Terraform-managed storage backends with dynamic status. Returns: @@ -48,70 +44,32 @@ def list_backends(self) -> List[StorageBackendInfo]: """ backends = [] client = self.deployment.get_client() - jhelper = JujuHelper(self.deployment.juju_controller) - - # Get all available backend types from registry - from .registry import StorageBackendRegistry - - registry = StorageBackendRegistry() - available_backends = registry.get_backends() + enabled_backends = client.cluster.get_storage_backends() # Search each backend type's individual config key - for backend_type, backend_instance in available_backends.items(): + for backend in enabled_backends.root: try: - # Each backend stores config in its - # own key: TerraformVarsStorageBackends{Type} - backend_config_key = backend_instance.tfvar_config_key - LOG.debug( - f"Searching for {backend_type} backends in config key: " - f"{backend_config_key}" + # Get actual application name from Terraform config + app_name = backend.name + + # Query actual status and charm from Juju + status = self._get_application_status(self.jhelper, app_name) + charm_name = self._get_application_charm(self.jhelper, app_name) + + backend_info = StorageBackendInfo( + name=backend.name, + backend_type=backend.type, + status=status, + charm=charm_name, + config=backend.config, ) - - current_config = read_config(client, backend_config_key) - - # Look for backend-specific keys (e.g., "hitachi_backends", - # "purestorage_backends") - backend_key = f"{backend_type}_backends" - - if backend_key in current_config: - for backend_name, backend_config in current_config[ - backend_key - ].items(): - try: - # Get actual application name from Terraform config - app_name = backend_config.get( - "application_name", backend_name - ) - - # Query actual status and charm from Juju - status = self._get_application_status(jhelper, app_name) - charm_name = self._get_application_charm(jhelper, app_name) - - backend = StorageBackendInfo( - name=backend_name, - backend_type=backend_type, - status=status, - charm=charm_name, - config=backend_config.get("charm_config", {}), - ) - backends.append(backend) - LOG.debug(f"Found {backend_type} backend: {backend_name}") - except Exception as e: - LOG.warning( - f"Error processing {backend_type}" - f"backend {backend_name}: {e}" - ) - continue - else: - LOG.debug(f"No {backend_key} found in config for {backend_type}") - - except ConfigItemNotFoundException: - LOG.debug(f"No configuration found for {backend_type} backends") - continue + backends.append(backend_info) + LOG.debug(f"Found {backend.type} backend: {backend.name}") except Exception as e: - LOG.warning(f"Error reading {backend_type} backends from clusterd: {e}") + LOG.warning( + f"Error processing {backend.type} backend {backend.name}: {e}" + ) continue - return backends def _get_application_status(self, jhelper: JujuHelper, app_name: str) -> str: @@ -168,81 +126,22 @@ def _get_application_charm(self, jhelper: JujuHelper, app_name: str) -> str: LOG.debug(f"Failed to get charm for application {app_name}: {e}") return "Unknown" - def _load_backend_tfvars(self, backend_type: str) -> Dict[str, Any]: - """Safely load storage backend Terraform variables from clusterd. - - Args: - backend_type: The backend type (e.g., 'hitachi', 'purestorage') - - Returns: - Dict containing the backend configuration, empty dict if not found. - """ - try: - # Get the backend instance to access its config key - from .registry import StorageBackendRegistry - - registry = StorageBackendRegistry() - available_backends = registry.get_backends() - - if backend_type not in available_backends: - return {} - - backend_instance = available_backends[backend_type] - backend_config_key = backend_instance.tfvar_config_key - - client = self.deployment.get_client() - return read_config(client, backend_config_key) - except ConfigItemNotFoundException: - return {} - except Exception: - return {} - - def _iter_backend_items(self, tfvars: Dict[str, Any], backend_type: str): - """Yield (name, config) pairs for a given backend_type.""" - typed_key = f"{backend_type}_backends" - typed_map = tfvars.get(typed_key, {}) or {} - if isinstance(typed_map, dict): - for name, cfg in typed_map.items(): - yield name, cfg - - def _get_backend_entry( - self, backend_type: str, backend_name: str - ) -> Dict[str, Any] | None: - """Return the backend config entry if found, else None.""" - tfvars = self._load_backend_tfvars(backend_type) - for name, cfg in self._iter_backend_items(tfvars, backend_type): - if name == backend_name: - return cfg - return None - def backend_exists(self, backend_name: str, backend_type: str) -> bool: """Check if a backend exists in Terraform configuration.""" - return self._get_backend_entry(backend_type, backend_name) is not None - - def get_backend_config( - self, backend_name: str, backend_type: str - ) -> Dict[str, Any]: - """Get the current configuration of a storage backend.""" + client = self.deployment.get_client() try: - entry = self._get_backend_entry(backend_type, backend_name) - if not entry: - raise BackendNotFoundException(f"Backend '{backend_name}' not found") - - # Return the full backend configuration, not just charm_config - # This includes credentials that can be masked by the display logic - full_config = dict(entry) - - # Merge charm_config into the top level for backward compatibility - charm_config = entry.get("charm_config", {}) - full_config.update(charm_config) - - # Remove the charm_config key to avoid showing it as a separate field - full_config.pop("charm_config", None) - - return full_config - - except BackendNotFoundException: - raise - except Exception as e: - LOG.error(f"Failed to get config for backend '{backend_name}': {e}") - raise StorageBackendException(f"Failed to get backend config: {e}") from e + backend = client.cluster.get_storage_backend(backend_name) + if backend.type != backend_type: + raise BackendAlreadyExistsException("Backend type mismatch.") + return True + except StorageBackendNotFoundException: + return False + + def get_backend(self, backend_name: str) -> StorageBackend: + """Get a specific storage backend by name.""" + client = self.deployment.get_client() + try: + return client.cluster.get_storage_backend(backend_name) + except StorageBackendNotFoundException as e: + LOG.debug(f"Storage backend not found: {backend_name}", exc_info=True) + raise BackendNotFoundException() from e diff --git a/sunbeam-python/sunbeam/storage/steps.py b/sunbeam-python/sunbeam/storage/steps.py index 2360cda3c..6f36cacb7 100644 --- a/sunbeam-python/sunbeam/storage/steps.py +++ b/sunbeam-python/sunbeam/storage/steps.py @@ -9,15 +9,18 @@ """ import logging -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Dict +from typing import TYPE_CHECKING +import pydantic import tenacity from rich.console import Console from rich.status import Status from sunbeam.clusterd.client import Client -from sunbeam.clusterd.service import ConfigItemNotFoundException +from sunbeam.clusterd.service import ( + ConfigItemNotFoundException, + StorageBackendNotFoundException, +) from sunbeam.core.common import ( BaseStep, Result, @@ -34,12 +37,24 @@ JujuHelper, ) from sunbeam.core.manifest import Manifest -from sunbeam.core.terraform import TerraformHelper, TerraformStateLockedException - -from .models import BackendNotFoundException, StorageBackendConfig +from sunbeam.core.questions import ( + ConfirmQuestion, + PasswordPromptQuestion, + PromptQuestion, + Question, + QuestionBank, + load_answers, + write_answers, +) +from sunbeam.core.terraform import ( + TerraformException, + TerraformHelper, + TerraformStateLockedException, +) +from sunbeam.storage.models import SecretDictField if TYPE_CHECKING: - from .base import StorageBackendBase + from sunbeam.storage.base import StorageBackendBase LOG = logging.getLogger(__name__) console = Console() @@ -161,7 +176,6 @@ def run(self, status: Status | None = None) -> Result: "Please ensure OpenStack storage services are deployed.", ) - console.print("✓ All storage prerequisites validated successfully") return Result(ResultType.COMPLETED) except Exception as e: @@ -169,12 +183,45 @@ def run(self, status: Status | None = None) -> Result: return Result(ResultType.FAILED, str(e)) -class BaseStorageBackendDeployStep(BaseStep, ABC): +def generate_required_questions_from_config( + config_type: type[pydantic.BaseModel], +) -> dict[str, Question]: + questions = {} # type: ignore + for field, finfo in config_type.model_fields.items(): + if not finfo.is_required(): + continue + question_type: type[Question] = PromptQuestion + for constraint in finfo.metadata: + if isinstance(constraint, SecretDictField): + question_type = PasswordPromptQuestion + questions[field] = question_type( + f"Enter value for {field!r}", description=finfo.description + ) + return questions + + +def generate_optional_questions_from_config( + config_type: type[pydantic.BaseModel], +) -> dict[str, Question]: + questions = {} # type: ignore + for field, finfo in config_type.model_fields.items(): + if finfo.is_required(): + continue + question_type: type[Question] = PromptQuestion + for constraint in finfo.metadata: + if isinstance(constraint, SecretDictField): + question_type = PasswordPromptQuestion + questions[field] = question_type( + f"Enter value for {field!r} (optional)", description=finfo.description + ) + return questions + + +class BaseStorageBackendDeployStep(BaseStep): """Base class for storage backend deployment steps. Provides common deployment functionality that backends can inherit from - and customize as needed. Backends should override get_terraform_variables() - and can override other methods for custom behavior. + and customize as needed. """ def __init__( @@ -184,10 +231,11 @@ def __init__( tfhelper: TerraformHelper, jhelper: JujuHelper, manifest: Manifest, + preseed: dict, backend_name: str, - backend_config: StorageBackendConfig, backend_instance: "StorageBackendBase", model: str, + accept_defaults: bool = False, ): super().__init__( f"Deploy {backend_instance.display_name} backend {backend_name}", @@ -199,18 +247,108 @@ def __init__( self.jhelper = jhelper self.manifest = manifest self.backend_name = backend_name - self.backend_config = backend_config self.backend_instance = backend_instance self.model = model + self.preseed = preseed + self.accept_defaults = accept_defaults + self.variables: dict = {} + self.config_key = self.backend_instance.config_key(self.backend_name) + + def prompt( + self, + console: Console | None = None, + show_hint: bool = False, + ) -> None: + """Determines if the step can take input from the user. + + Prompts are used by Steps to gather the necessary input prior to + running the step. Steps should not expect that the prompt will be + available and should provide a reasonable default where possible. + """ + self.variables = load_answers(self.client, self.config_key) + + preseed = {} + if self.manifest and self.manifest.storage: + if backends := self.manifest.storage.root.get( + self.backend_instance.backend_type + ): + if crt := backends.root.get(self.backend_name): + # Since question generation depends on field name, + # do not dump by alias + preseed = crt.model_dump(by_alias=False)["config"] + + # Preseed from user is higher priority than manifest + preseed.update(self.preseed) + + manifest_configured = False + + if preseed: + manifest_configured = True + + required_questions_bank = QuestionBank( + questions=generate_required_questions_from_config( + self.backend_instance.config_type() + ), + console=console, + preseed=preseed, + previous_answers=self.variables, + accept_defaults=self.accept_defaults, + show_hint=show_hint, + ) + for name, question in required_questions_bank.questions.items(): + self.variables[name] = question.ask() + + res = ConfirmQuestion( + "Set optional configurations?", + accept_defaults=self.accept_defaults, + default_value=manifest_configured, + ).ask() + + if not res: + write_answers(self.client, self.config_key, self.variables) + return + + optional_questions_bank = QuestionBank( + questions=generate_optional_questions_from_config( + self.backend_instance.config_type() + ), + console=console, + preseed=preseed, + previous_answers=self.variables, + accept_defaults=self.accept_defaults, + show_hint=show_hint, + ) - @abstractmethod - def get_terraform_variables(self) -> Dict[str, Any]: - """Get Terraform variables for this backend deployment. + for name, question in optional_questions_bank.questions.items(): + if ConfirmQuestion( + f"Configure option {name!r}?", + accept_defaults=self.accept_defaults, + default_value=name in preseed, + ).ask(): + self.variables[name] = question.ask() + else: + # Remove variable if previously set for + # subsequent runs + self.variables.pop(name, None) + + try: + # Validate configuration + self.backend_instance.config_type().model_validate( + self.variables, by_name=True + ) + except pydantic.ValidationError as e: + LOG.error(f"Invalid configuration: {e}") + raise e - Backends must implement this method to provide their specific - Terraform variables for deployment. + write_answers(self.client, self.config_key, self.variables) + + def has_prompts(self) -> bool: + """Returns true if the step has prompts that it can ask the user. + + :return: True if the step can ask the user for prompts, + False otherwise """ - pass + return True @tenacity.retry( wait=tenacity.wait_fixed(60), @@ -224,47 +362,45 @@ def get_terraform_variables(self) -> Dict[str, Any]: ) def run(self, status: Status | None = None) -> Result: """Deploy the storage backend using Terraform.""" + # Ensure fresh Juju credentials and Terraform env before applying try: - # Ensure fresh Juju credentials and Terraform env before applying - try: - self.deployment.reload_tfhelpers() - except Exception as cred_err: - LOG.debug(f"Failed to reload credentials/env: {cred_err}") + self.deployment.reload_tfhelpers() + except Exception as cred_err: + LOG.debug(f"Failed to reload credentials/env: {cred_err}") - # Get Terraform variables for this backend (contains a single backend entry) - tf_vars = self.get_terraform_variables() + # Merge with existing backends so we don't overwrite them + backend_key = self.backend_name + try: + tfvars = read_config(self.client, self.backend_instance.tfvar_config_key) + except Exception: + tfvars = {} - # Merge with existing backends so we don't overwrite them - backend_key = f"{self.backend_instance.name}_backends" - try: - current_tfvars = read_config( - self.client, self.backend_instance.tfvar_config_key - ) - current_backends = ( - current_tfvars.get(backend_key, {}) if current_tfvars else {} - ) - except Exception: - current_backends = {} + model = self.jhelper.get_model(self.model) + + backends = tfvars.setdefault("backends", {}) - # The new backend map is at tf_vars[backend_key] - new_backends = tf_vars.get(backend_key, {}) - merged_backends = {**current_backends, **new_backends} - tf_vars[backend_key] = merged_backends + tfvars["model"] = model["model-uuid"] + # Remove backend if in current config, to ensure we remove the keys + # no longer used + backends.pop(backend_key, None) + validated_config = self.backend_instance.config_type().model_validate( + self.variables, by_name=True + ) + backends[backend_key] = self.backend_instance.build_terraform_vars( + self.deployment, + self.manifest, + self.backend_name, + validated_config, + ) + try: # Update Terraform variables and apply with merged map self.tfhelper.update_tfvars_and_apply_tf( self.client, self.manifest, tfvar_config=self.backend_instance.tfvar_config_key, - override_tfvars=tf_vars, - ) - - console.print( - f"Successfully deployed {self.backend_instance.display_name} " - f"backend '{self.backend_name}'" + override_tfvars=tfvars, ) - return Result(ResultType.COMPLETED) - except TerraformStateLockedException as e: # Bubble up to trigger retry raise e @@ -274,6 +410,31 @@ def run(self, status: Status | None = None) -> Result: f"backend {self.backend_name}: {e}" ) return Result(ResultType.FAILED, str(e)) + # Let's save backend if not present + self.client.cluster.add_storage_backend( + self.backend_name, + self.backend_instance.backend_type, + validated_config.model_dump(exclude_none=True, by_alias=True), + self.backend_instance.principal_application, + model["model-uuid"], + ) + + try: + self.jhelper.wait_application_ready( + self.backend_name, + model["model-uuid"], + accepted_status=self.get_accepted_application_status(), + timeout=self.get_application_timeout(), + ) + except TimeoutError as e: + LOG.warning(str(e)) + return Result(ResultType.FAILED, str(e)) + + console.print( + f"Successfully deployed {self.backend_instance.display_name} " + f"backend {self.backend_name!r}" + ) + return Result(ResultType.COMPLETED) def get_application_timeout(self) -> int: """Return application timeout in seconds. Override for custom timeout.""" @@ -281,10 +442,10 @@ def get_application_timeout(self) -> int: def get_accepted_application_status(self) -> list[str]: """Return accepted application status.""" - return ["active", "waiting"] + return ["active"] -class BaseStorageBackendDestroyStep(BaseStep, ABC): +class BaseStorageBackendDestroyStep(BaseStep): """Base class for storage backend destruction steps. Provides common destruction functionality that backends can inherit from @@ -317,36 +478,6 @@ def __init__( self.backend_instance = backend_instance self.model = model - def should_destroy_all_resources(self) -> bool: - """Check if all resources should be destroyed (no backends left). - - Override this method if backend has custom logic for determining - when to destroy all resources vs just removing configuration. - """ - try: - current_config = read_config( - self.client, self.backend_instance.tfvar_config_key - ) - - backend_key = ( - f"{self.backend_instance.name}_backends" # e.g., "hitachi_backends" - ) - - if backend_key in current_config: - backends = current_config[backend_key] - else: - raise BackendNotFoundException( - f"Backend '{self.backend_name}' not found" - ) - - # Remove this backend from the count - backends_without_current = { - k: v for k, v in backends.items() if k != self.backend_name - } - return len(backends_without_current) == 0 - except ConfigItemNotFoundException: - return True - @tenacity.retry( wait=tenacity.wait_fixed(60), stop=tenacity.stop_after_delay(300), @@ -365,278 +496,76 @@ def run(self, status: Status | None = None) -> Result: The operation is atomic: either it succeeds completely or fails without modifying the configuration. """ + # Ensure fresh Juju credentials and Terraform env before destroying/applying try: - # Ensure fresh Juju credentials and Terraform env before destroying/applying - try: - self.deployment.reload_tfhelpers() - except Exception as cred_err: - LOG.debug(f"Failed to reload credentials/env: {cred_err}") - - # First, read and validate the current configuration - try: - current_config = read_config( - self.client, self.backend_instance.tfvar_config_key - ) - except ConfigItemNotFoundException: - LOG.warning(f"No configuration found for backend {self.backend_name}") - raise BackendNotFoundException( - f"No Terraform configuration found for backend " - f"'{self.backend_name}'" - ) - - # Check if backend exists in configuration - backend_key = ( - f"{self.backend_instance.name}_backends" # e.g., "hitachi_backends" - ) - - if ( - backend_key not in current_config - or self.backend_name not in current_config[backend_key] - ): - LOG.warning(f"Backend {self.backend_name} not found in configuration") - raise BackendNotFoundException( - f"Backend '{self.backend_name}' not found in Terraform" - f"configuration. This may indicate a state inconsistency." - ) + self.deployment.reload_tfhelpers() + except Exception as cred_err: + LOG.debug(f"Failed to reload credentials/env: {cred_err}") - # Create a backup of the backend configuration before removal - backend_backup = current_config[backend_key][self.backend_name].copy() + # First, read and validate the current configuration + try: + tfvars = read_config(self.client, self.backend_instance.tfvar_config_key) + except ConfigItemNotFoundException: + LOG.warning(f"No configuration found for backend {self.backend_name}") + tfvars = {} - # Remove backend from configuration (in memory only) - del current_config[backend_key][self.backend_name] + backends = tfvars.get("backends", {}) - # Determine if we need to destroy all resources or just apply changes - destroy_all = self.should_destroy_all_resources() + # Drop backend from current configuration + backends.pop(self.backend_name, None) - try: - if destroy_all: - # For complete destruction: first update config, then destroy - # If destroy fails, we can restore the config - update_config( - self.client, - self.backend_instance.tfvar_config_key, - current_config, - ) + # For removal: update config and apply atomically + LOG.info(f"Performing removal for backend {self.backend_name}") + LOG.info(f"Remaining backends after removal: {list(tfvars['backends'].keys())}") - try: - self.tfhelper.destroy() - except Exception as destroy_error: - # Restore the backend configuration if destroy fails - LOG.error( - f"""Terraform destroy failed, - restoring configuration: {destroy_error}""" - ) - current_config[backend_key][self.backend_name] = backend_backup - update_config( - self.client, - self.backend_instance.tfvar_config_key, - current_config, - ) - raise destroy_error - else: - # For partial removal: update config and apply atomically - LOG.info( - f"Performing partial removal for backend {self.backend_name}" - ) - LOG.info( - f"Remaining backends after removal: " - f"{list(current_config[backend_key].keys())}" - ) + # First update the configuration + update_config( + self.client, + self.backend_instance.tfvar_config_key, + tfvars, + ) + LOG.info("Configuration updated, now running terraform apply...") - # First update the configuration - update_config( - self.client, - self.backend_instance.tfvar_config_key, - current_config, - ) - LOG.info("Configuration updated, now running terraform apply...") - - try: - LOG.info("Starting terraform apply for partial removal") - # CRITICAL: Write the updated Terraform variables - # before applying - # This was missing and causing partial removal to fail! - LOG.info("Writing updated Terraform variables...") - - # Get the updated Terraform variables from the current config - tf_vars = current_config.copy() - LOG.info( - f"Writing Terraform variables with backends: " - f"{list(tf_vars.get(backend_key, {}).keys())}" - ) - - self.tfhelper.write_tfvars(tf_vars) - LOG.info("Terraform variables written, now applying...") - self.tfhelper.apply() - LOG.info( - "Terraform apply completed successfully for partial removal" - ) - except Exception as apply_error: - # Restore the backend configuration if apply fails - LOG.error( - f"Terraform apply failed, restoring configuration: " - f"{apply_error}" - ) - current_config[backend_key][self.backend_name] = backend_backup - update_config( - self.client, - self.backend_instance.tfvar_config_key, - current_config, - ) - raise apply_error - - except Exception as tf_error: - # Any Terraform operation failure should be propagated - raise tf_error - - console.print( - f"Successfully removed {self.backend_instance.display_name} " - f"backend '{self.backend_name}'" + try: + LOG.info( + f"Writing Terraform variables with backends: " + f"{list(tfvars.get('backends', {}).keys())}" + ) + self.tfhelper.update_tfvars_and_apply_tf( + self.client, + self.manifest, + tfvar_config=self.backend_instance.tfvar_config_key, + override_tfvars=tfvars, ) - return Result(ResultType.COMPLETED) - except TerraformStateLockedException as e: # Bubble up to trigger retry + LOG.debug("Error: Terraform state locked") raise e - except Exception as e: - LOG.error( - f"Failed to destroy {self.backend_instance.display_name} " - f"backend {self.backend_name}: {e}" + except TerraformException: + # Restore the backend configuration if apply fails + LOG.debug("Terraform apply failed", exc_info=True) + return Result( + ResultType.FAILED, + f"Failed to destroy backend {self.backend_name!r}", ) - return Result(ResultType.FAILED, str(e)) - - def get_application_timeout(self) -> int: - """Return application timeout in seconds.""" - return 1200 # 20 minutes, same as cinder-volume - - -class BaseStorageBackendConfigUpdateStep(BaseStep, ABC): - """Base class for storage backend configuration update steps. - Provides common configuration update functionality that backends can inherit from - and customize as needed. Handles configuration updates and reset operations. - """ - - def __init__( - self, - deployment: Deployment, - backend_instance: "StorageBackendBase", - backend_name: str, - config_updates: Dict[str, Any], - ): - super().__init__( - f"Update {backend_instance.display_name} backend config {backend_name}", - f"Updating {backend_instance.display_name} storage backend " - f"configuration for {backend_name}", - ) - self.deployment = deployment - self.backend_instance = backend_instance - self.backend_name = backend_name - self.config_updates = config_updates - self.client = deployment.get_client() - self.tfhelper = deployment.get_tfhelper(backend_instance.tfplan) - - def handle_update_operation(self, current_config: Dict[str, Any]) -> Dict[str, Any]: - """Handle configuration update operation. Override for custom update logic. - - Args: - current_config: Current backend configuration - - Returns: - Updated configuration with new values applied - """ - # Get backend config from new format only - backend_key = ( - f"{self.backend_instance.name}_backends" # e.g., "hitachi_backends" - ) - backend_config = current_config[backend_key][self.backend_name] - if "charm_config" not in backend_config: - backend_config["charm_config"] = {} - - # Apply configuration updates (excluding reset keys) with field mapping - updates = {k: v for k, v in self.config_updates.items() if k != "_reset_keys"} - - # Apply field mapping to convert internal field names to charm field names - field_mapping = self.backend_instance.get_field_mapping() - mapped_updates = {} - for key, value in updates.items(): - # Use field mapping if available, otherwise use the key as-is - charm_key = field_mapping.get(key, key) - mapped_updates[charm_key] = value - - backend_config["charm_config"].update(mapped_updates) - - return current_config - - @tenacity.retry( - wait=tenacity.wait_fixed(60), - stop=tenacity.stop_after_delay(300), - retry=tenacity.retry_if_exception_type(TerraformStateLockedException), - retry_error_callback=friendly_terraform_lock_retry_callback, - before_sleep=lambda retry_state: console.print( - f"Terraform state locked, retrying in 60 seconds... " - f"(attempt {retry_state.attempt_number}/5)" - ), - ) - def run(self, status: Status | None = None) -> Result: - """Update the storage backend configuration using Terraform.""" - # Read current configuration try: - current_config = read_config( - self.client, self.backend_instance.tfvar_config_key - ) - - # Check new format (backend-specific keys only) - backend_key = ( - f"{self.backend_instance.name}_backends" # e.g., "hitachi_backends" - ) - - if ( - backend_key not in current_config - or self.backend_name not in current_config[backend_key] - ): - return Result( - ResultType.FAILED, f"Backend {self.backend_name} not found" - ) - - # Handle update operation - current_config = self.handle_update_operation(current_config) - operation_type = "update" - - # Save updated configuration and apply with updated tfvars - update_config( - self.client, self.backend_instance.tfvar_config_key, current_config - ) + self.client.cluster.delete_storage_backend(self.backend_name) + except StorageBackendNotFoundException: + LOG.debug(f"Backend {self.backend_name} not found in clusterd") - # Ensure fresh Juju credentials and Terraform env before applying - try: - self.deployment.reload_tfhelpers() - except Exception as cred_err: - LOG.debug(f"Failed to reload credentials/env: {cred_err}") - - # Write the updated tfvars and apply - self.tfhelper.write_tfvars(current_config) - self.tfhelper.apply() - - console.print( - f"Successfully {operation_type}d " - f"{self.backend_instance.display_name} backend " - f"'{self.backend_name}' configuration" + try: + # Wipe previously saved answers + self.client.cluster.delete_config( + self.backend_instance.config_key(self.backend_name) ) - return Result(ResultType.COMPLETED) - except ConfigItemNotFoundException: - return Result( - ResultType.FAILED, - f"Configuration not found for backend {self.backend_name}", + LOG.debug( + f"Configuration for backend {self.backend_name} not found in clusterd" ) - except TerraformStateLockedException as e: - # Bubble up to trigger retry - raise e - except Exception as e: - LOG.error( - f"Failed to update {self.backend_instance.display_name} " - f"backend {self.backend_name} configuration: {e}" - ) - return Result(ResultType.FAILED, str(e)) + return Result(ResultType.COMPLETED) + + def get_application_timeout(self) -> int: + """Return application timeout in seconds.""" + return 1200 # 20 minutes, same as cinder-volume diff --git a/sunbeam-python/sunbeam/utils.py b/sunbeam-python/sunbeam/utils.py index 5d8d520df..50146fb97 100644 --- a/sunbeam-python/sunbeam/utils.py +++ b/sunbeam-python/sunbeam/utils.py @@ -18,8 +18,9 @@ import click import netifaces # type: ignore [import-untyped] +import pydantic.alias_generators -from sunbeam.core.common import SunbeamException +from sunbeam.errors import SunbeamException from sunbeam.lazy import LazyImport if typing.TYPE_CHECKING: @@ -408,3 +409,8 @@ def clean_env(): for key in os.environ: if key.startswith("OS_"): os.environ.pop(key) + + +def to_kebab(value: str) -> str: + """Convert a string to kebab-case.""" + return pydantic.alias_generators.to_snake(value).replace("_", "-") diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/hitachi/__init__.py b/sunbeam-python/tests/unit/sunbeam/storage/__init__.py similarity index 66% rename from sunbeam-python/tests/unit/sunbeam/storage/backends/hitachi/__init__.py rename to sunbeam-python/tests/unit/sunbeam/storage/__init__.py index f2acd69a0..12519b28d 100644 --- a/sunbeam-python/tests/unit/sunbeam/storage/backends/hitachi/__init__.py +++ b/sunbeam-python/tests/unit/sunbeam/storage/__init__.py @@ -1,4 +1,2 @@ # SPDX-FileCopyrightText: 2025 - Canonical Ltd # SPDX-License-Identifier: Apache-2.0 - -"""Tests for Hitachi storage backend.""" diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/__init__.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/__init__.py index 9c90cc6d9..e69de29bb 100644 --- a/sunbeam-python/tests/unit/sunbeam/storage/backends/__init__.py +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/__init__.py @@ -1,4 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -"""Tests for storage backends.""" diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py new file mode 100644 index 000000000..f931caf25 --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Common fixtures and utilities for backend-specific tests.""" + +import pytest + +from sunbeam.storage.backends.dellsc.backend import DellSCBackend +from sunbeam.storage.backends.hitachi.backend import HitachiBackend +from sunbeam.storage.backends.purestorage.backend import PureStorageBackend + + +@pytest.fixture +def hitachi_backend(): + """Provide a Hitachi backend instance.""" + return HitachiBackend() + + +@pytest.fixture +def purestorage_backend(): + """Provide a Pure Storage backend instance.""" + return PureStorageBackend() + + +@pytest.fixture +def dellsc_backend(): + """Provide a Dell Storage Center backend instance.""" + return DellSCBackend() + + +@pytest.fixture(params=["hitachi", "purestorage", "dellsc"]) +def any_backend(request): + """Parametrized fixture that provides each backend type.""" + backends = { + "hitachi": HitachiBackend(), + "purestorage": PureStorageBackend(), + "dellsc": DellSCBackend(), + } + return backends[request.param] diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/hitachi/test_cli.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/hitachi/test_cli.py deleted file mode 100644 index 238e15718..000000000 --- a/sunbeam-python/tests/unit/sunbeam/storage/backends/hitachi/test_cli.py +++ /dev/null @@ -1,367 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -"""Tests for Hitachi storage backend CLI commands.""" - -import unittest.mock as mock - -import click -from click.testing import CliRunner - -from sunbeam.storage.backends.hitachi.backend import HitachiBackend, HitachiConfig -from sunbeam.storage.backends.hitachi.cli import HitachiCLI - - -class TestHitachiCLI: - """Test cases for Hitachi storage backend CLI commands.""" - - def setup_method(self): - """Set up test fixtures.""" - self.runner = CliRunner() - self.mock_deployment = mock.MagicMock() - self.backend = HitachiBackend() - self.cli = HitachiCLI(self.backend) - - def test_register_add_cli(self): - """Test that register_add_cli creates a command properly.""" - mock_add_group = mock.MagicMock() - - # Test that register_add_cli doesn't raise an exception - self.cli.register_add_cli(mock_add_group) - - # Verify that a command was added to the group - mock_add_group.add_command.assert_called_once() - - # Get the command that was added - added_command = mock_add_group.add_command.call_args[0][0] - assert isinstance(added_command, click.Command) - assert added_command.name == "hitachi" - - def test_load_config_file_yaml(self): - """Test loading YAML config file.""" - import tempfile - - import yaml - - config_data = { - "name": "test-backend", - "hitachi_storage_id": "12345", - "san_ip": "192.168.1.100", - } - - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - yaml.dump(config_data, f) - f.flush() - - from pathlib import Path - - result = self.cli._load_config_file(Path(f.name)) - - assert result == config_data - - def test_add_missing_required(self): - """Test that Click exits non-zero with missing required option.""" - # Create a mock add group and register the command - add_group = click.Group(name="add") - self.cli.register_add_cli(add_group) - - result = self.runner.invoke( - add_group, - [ - "hitachi", - "--name", - "test-hitachi", - # Missing required --hitachi-storage-id - "--hitachi-pools", - "pool1,pool2", - "--san-ip", - "192.168.1.100", - ], - obj=self.mock_deployment, - ) - - assert result.exit_code != 0 - assert "Missing option" in result.output or "required" in result.output.lower() - - def test_register_cli(self): - """Test that register_cli creates management commands properly.""" - mock_remove = mock.MagicMock() - mock_config_show = mock.MagicMock() - mock_config_set = mock.MagicMock() - mock_config_options = mock.MagicMock() - - # Test that register_cli doesn't raise an exception - self.cli.register_cli( - mock_remove, - mock_config_show, - mock_config_set, - mock_config_options, - self.mock_deployment, - ) - - # Verify that commands were added to each group - mock_remove.add_command.assert_called_once() - mock_config_show.add_command.assert_called_once() - mock_config_set.add_command.assert_called_once() - mock_config_options.add_command.assert_called_once() - - def test_cli_methods_exist(self): - """Test that all required CLI methods exist.""" - assert hasattr(self.cli, "register_add_cli") - assert hasattr(self.cli, "register_cli") - assert hasattr(self.cli, "_load_config_file") - - # Test they are callable - assert callable(self.cli.register_add_cli) - assert callable(self.cli.register_cli) - assert callable(self.cli._load_config_file) - - def test_load_config_file_json(self): - """Test loading JSON config file.""" - import json - import tempfile - - config_data = { - "name": "test-backend", - "hitachi_storage_id": "12345", - "san_ip": "192.168.1.100", - } - - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(config_data, f) - f.flush() - - from pathlib import Path - - result = self.cli._load_config_file(Path(f.name)) - - assert result == config_data - - def test_load_config_file_empty_path(self): - """Test loading config file with empty path returns empty dict.""" - result = self.cli._load_config_file(None) - assert result == {} - - def test_cli_backend_reference(self): - """Test that CLI maintains reference to backend.""" - assert self.cli.backend is not None - assert isinstance(self.cli.backend, HitachiBackend) - assert self.cli.backend.name == "hitachi" - - def test_add_with_chap_auth(self): - """Test addition with CHAP authentication enabled.""" - with mock.patch.object(self.backend, "add_backend") as mock_add_backend: - mock_add_backend.return_value = None - - # Create a mock add group and register the command - add_group = click.Group(name="add") - self.cli.register_add_cli(add_group) - - result = self.runner.invoke( - add_group, - [ - "hitachi", - "--name", - "test-hitachi", - "--hitachi-storage-id", - "12345", - "--hitachi-pools", - "pool1,pool2", - "--san-ip", - "192.168.1.100", - "--san-username", - "sanuser", - "--san-password", - "sanpass", - "--protocol", - "iSCSI", - "--chap-username", - "chapuser", - "--chap-password", - "chappass", - "--use-chap-auth", - "true", - ], - obj=self.mock_deployment, - ) - - assert result.exit_code == 0 - mock_add_backend.assert_called_once() - - # Verify the config passed to add_backend - call_args = mock_add_backend.call_args - config = call_args[0][2] # Third argument is the config - assert config.use_chap_auth is True - assert config.chap_username == "chapuser" - assert config.chap_password == "chappass" - - def test_add_backend_exception(self): - """Test that backend exceptions are handled gracefully.""" - with mock.patch.object(self.backend, "add_backend") as mock_add_backend: - mock_add_backend.side_effect = Exception("Backend error") - - # Create a mock add group and register the command - add_group = click.Group(name="add") - self.cli.register_add_cli(add_group) - - result = self.runner.invoke( - add_group, - [ - "hitachi", - "--name", - "test-hitachi", - "--hitachi-storage-id", - "12345", - "--hitachi-pools", - "pool1,pool2", - "--san-ip", - "192.168.1.100", - "--san-username", - "sanuser", - "--san-password", - "sanpass", - "--protocol", - "iSCSI", - ], - obj=self.mock_deployment, - ) - - assert result.exit_code != 0 - # The exception is raised but not caught by CLI, so no output is generated - # The exception object itself contains the error message - assert result.exception is not None - assert "Backend error" in str(result.exception) - - def test_cli_initialization(self): - """Test that HitachiCLI can be initialized with a backend.""" - backend = HitachiBackend() - cli = HitachiCLI(backend) - assert cli.backend == backend - - def test_backend_delegation_works(self): - """Test that CLI properly delegates to backend methods.""" - # Test that CLI can access backend properties - assert self.cli.backend.name == "hitachi" - assert self.cli.backend.display_name == "Hitachi VSP Storage" - assert self.cli.backend.config_class == HitachiConfig - - def test_register_add_cli_creates_command_with_correct_name(self): - """Test that register_add_cli creates a command with the backend name.""" - mock_add_group = mock.MagicMock() - - self.cli.register_add_cli(mock_add_group) - - # Verify command was added with correct name - mock_add_group.add_command.assert_called_once() - added_command = mock_add_group.add_command.call_args[0][0] - assert added_command.name == self.backend.name - - def test_register_cli_creates_all_management_commands(self): - """Test that register_cli creates all expected management commands.""" - mock_remove = mock.MagicMock() - mock_config_show = mock.MagicMock() - mock_config_set = mock.MagicMock() - mock_config_options = mock.MagicMock() - - self.cli.register_cli( - mock_remove, - mock_config_show, - mock_config_set, - mock_config_options, - self.mock_deployment, - ) - - # Verify all commands were created with correct names - for mock_group in [ - mock_remove, - mock_config_show, - mock_config_set, - mock_config_options, - ]: - mock_group.add_command.assert_called_once() - added_command = mock_group.add_command.call_args[0][0] - assert added_command.name == self.backend.name - - def test_remove_ok(self): - """Test successful removal of Hitachi backend.""" - with ( - mock.patch.object(self.backend, "_get_service") as mock_get_service, - mock.patch.object(self.backend, "remove_backend") as mock_remove_backend, - ): - mock_service = mock.MagicMock() - mock_service.backend_exists.return_value = True - mock_get_service.return_value = mock_service - mock_remove_backend.return_value = None - - # Create a mock remove group and register the command - remove_group = click.Group(name="remove") - config_show = click.Group(name="config_show") - config_set = click.Group(name="config_set") - config_options = click.Group(name="config_options") - self.cli.register_cli( - remove_group, - config_show, - config_set, - config_options, - self.mock_deployment, - ) - - result = self.runner.invoke( - remove_group, - ["hitachi", "test-hitachi", "--yes"], - obj=self.mock_deployment, - ) - - assert result.exit_code == 0 - mock_get_service.assert_called_once_with(self.mock_deployment) - mock_service.backend_exists.assert_called_once_with( - "test-hitachi", "hitachi" - ) - mock_remove_backend.assert_called_once_with( - self.mock_deployment, "test-hitachi", mock.ANY - ) - - def test_remove_service_exception(self): - """Test handling of service exceptions during removal.""" - with ( - mock.patch.object(self.backend, "_get_service") as mock_get_service, - mock.patch.object(self.backend, "remove_backend") as mock_remove_backend, - ): - mock_service = mock.MagicMock() - mock_service.backend_exists.return_value = True - mock_get_service.return_value = mock_service - mock_remove_backend.side_effect = Exception("Service error") - - # Create a mock remove group and register the command - remove_group = click.Group(name="remove") - config_show = click.Group(name="config_show") - config_set = click.Group(name="config_set") - config_options = click.Group(name="config_options") - self.cli.register_cli( - remove_group, - config_show, - config_set, - config_options, - self.mock_deployment, - ) - - result = self.runner.invoke( - remove_group, - ["hitachi", "test-hitachi", "--yes"], - obj=self.mock_deployment, - ) - - assert result.exit_code != 0 - assert "Service" in result.output and "error" in result.output - - def test_cli_class_has_required_methods(self): - """Test that HitachiCLI class has all required methods.""" - required_methods = [ - "register_add_cli", - "register_cli", - "_load_config_file", - ] - - for method_name in required_methods: - assert hasattr(self.cli, method_name) - assert callable(getattr(self.cli, method_name)) diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/test_common.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_common.py new file mode 100644 index 000000000..43062b198 --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_common.py @@ -0,0 +1,224 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Common base test class for all storage backends. + +This module provides a base test class that can be inherited by backend-specific +test classes to ensure all backends implement the required interface correctly. +""" + +import pytest +from pydantic import BaseModel + +from sunbeam.core.manifest import StorageBackendConfig +from sunbeam.storage.base import StorageBackendBase + + +class BaseBackendTests: + """Base test class for all storage backends. + + This class provides common tests that verify each backend implements + the required interface and behaves correctly. Backend-specific test + classes should inherit from this class and override the `backend` fixture + to provide their specific backend instance. + + Example: + class TestHitachiBackend(BaseBackendTests): + @pytest.fixture + def backend(self, hitachi_backend): + return hitachi_backend + """ + + @pytest.fixture + def backend(self): + """Override this fixture in subclasses to provide the backend instance. + + Raises: + NotImplementedError: If not overridden in subclass. + """ + raise NotImplementedError( + "Subclasses must override the backend fixture to provide a backend instance" + ) + + # Core attribute tests + + def test_backend_has_type(self, backend): + """Test that backend has a type identifier.""" + assert hasattr(backend, "backend_type") + assert isinstance(backend.backend_type, str) + assert len(backend.backend_type) > 0 + + def test_backend_has_display_name(self, backend): + """Test that backend has a display name.""" + assert hasattr(backend, "display_name") + assert isinstance(backend.display_name, str) + assert len(backend.display_name) > 0 + + def test_backend_is_storage_backend_base(self, backend): + """Test that backend inherits from StorageBackendBase.""" + assert isinstance(backend, StorageBackendBase) + + # Charm property tests + + def test_charm_name_is_set(self, backend): + """Test that charm_name property is set.""" + assert backend.charm_name + assert isinstance(backend.charm_name, str) + assert backend.charm_name.startswith("cinder-volume-") + + def test_charm_channel_is_set(self, backend): + """Test that charm_channel property is set.""" + assert backend.charm_channel + assert isinstance(backend.charm_channel, str) + + def test_charm_base_is_set(self, backend): + """Test that charm_base property is set.""" + assert backend.charm_base + assert isinstance(backend.charm_base, str) + + def test_backend_endpoint_is_set(self, backend): + """Test that backend_endpoint property is set.""" + assert backend.backend_endpoint + assert isinstance(backend.backend_endpoint, str) + + def test_units_is_positive(self, backend): + """Test that units property returns a positive integer.""" + assert isinstance(backend.units, int) + assert backend.units > 0 + + def test_additional_integrations_is_list(self, backend): + """Test that additional_integrations returns a list.""" + integrations = backend.additional_integrations + assert isinstance(integrations, list) + assert all(isinstance(i, str) for i in integrations) + + # Configuration tests + + def test_config_type_returns_class(self, backend): + """Test that config_type() returns a class.""" + config_class = backend.config_type() + assert isinstance(config_class, type) + + def test_config_type_is_storage_backend_config(self, backend): + """Test that config_type() returns a StorageBackendConfig subclass.""" + config_class = backend.config_type() + assert issubclass(config_class, StorageBackendConfig) + + def test_config_type_is_pydantic_model(self, backend): + """Test that config_type() returns a Pydantic model.""" + config_class = backend.config_type() + assert issubclass(config_class, BaseModel) + + # Required field tests + + def test_config_has_san_ip_field(self, backend): + """Test that config has san_ip field.""" + config_class = backend.config_type() + assert "san_ip" in config_class.model_fields + + def test_config_has_protocol_field(self, backend): + """Test that config has protocol field.""" + config_class = backend.config_type() + # Protocol field should exist + assert "protocol" in config_class.model_fields + + # Method existence tests + + def test_has_get_endpoint_bindings_method(self, backend): + """Test that backend has get_endpoint_bindings method.""" + assert hasattr(backend, "get_endpoint_bindings") + assert callable(backend.get_endpoint_bindings) + + def test_has_validate_ip_or_fqdn_static_method(self, backend): + """Test that backend class has _validate_ip_or_fqdn static method.""" + # This is a static method on the base class + assert hasattr(StorageBackendBase, "_validate_ip_or_fqdn") + assert callable(StorageBackendBase._validate_ip_or_fqdn) + + def test_has_register_terraform_plan_method(self, backend): + """Test that backend has register_terraform_plan method.""" + assert hasattr(backend, "register_terraform_plan") + assert callable(backend.register_terraform_plan) + + def test_has_add_backend_instance_method(self, backend): + """Test that backend has add_backend_instance method.""" + assert hasattr(backend, "add_backend_instance") + assert callable(backend.add_backend_instance) + + def test_has_remove_backend_method(self, backend): + """Test that backend has remove_backend method.""" + assert hasattr(backend, "remove_backend") + assert callable(backend.remove_backend) + + def test_has_build_terraform_vars_method(self, backend): + """Test that backend has build_terraform_vars method.""" + assert hasattr(backend, "build_terraform_vars") + assert callable(backend.build_terraform_vars) + + +class TestAllBackends(BaseBackendTests): + """Test all backends using parametrized fixture. + + This class runs the common tests against all backends to ensure + they all implement the required interface consistently. + """ + + @pytest.fixture + def backend(self, any_backend): + """Use the parametrized any_backend fixture.""" + return any_backend + + +# Backend uniqueness tests + + +def test_all_backends_have_unique_types( + hitachi_backend, purestorage_backend, dellsc_backend +): + """Test that all backends have unique type identifiers.""" + backends = [hitachi_backend, purestorage_backend, dellsc_backend] + types = [b.backend_type for b in backends] + + # Check no duplicates + assert len(types) == len(set(types)), f"Duplicate backend types found: {types}" + + +def test_all_backends_have_unique_charm_names( + hitachi_backend, purestorage_backend, dellsc_backend +): + """Test that all backends have unique charm names.""" + backends = [hitachi_backend, purestorage_backend, dellsc_backend] + charm_names = [b.charm_name for b in backends] + + # Check no duplicates + assert len(charm_names) == len(set(charm_names)), ( + f"Duplicate charm names found: {charm_names}" + ) + + +@pytest.mark.parametrize( + "backend_type,expected_type", + [ + ("hitachi", "hitachi"), + ("purestorage", "purestorage"), + ("dellsc", "dellsc"), + ], +) +def test_backend_types_match_expected(any_backend, backend_type, expected_type): + """Test that backend types match expected values.""" + if any_backend.backend_type == backend_type: + assert any_backend.backend_type == expected_type + + +@pytest.mark.parametrize( + "backend_type,expected_charm", + [ + ("hitachi", "cinder-volume-hitachi"), + ("purestorage", "cinder-volume-purestorage"), + ("dellsc", "cinder-volume-dellsc"), + ], +) +def test_backend_charm_names_match_expected(any_backend, backend_type, expected_charm): + """Test that backend charm names match expected values.""" + if any_backend.backend_type == backend_type: + assert any_backend.charm_name == expected_charm diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/test_dellsc.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_dellsc.py new file mode 100644 index 000000000..1fe74efb6 --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_dellsc.py @@ -0,0 +1,322 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for Dell Storage Center backend.""" + +import pytest + +from tests.unit.sunbeam.storage.backends.test_common import BaseBackendTests + + +class TestDellSCBackend(BaseBackendTests): + """Tests for Dell Storage Center backend. + + Inherits all generic tests from BaseBackendTests and adds + backend-specific tests. + """ + + @pytest.fixture + def backend(self, dellsc_backend): + """Provide Dell SC backend instance.""" + return dellsc_backend + + # Backend-specific tests + + def test_backend_type_is_dellsc(self, backend): + """Test that backend type is 'dellsc'.""" + assert backend.backend_type == "dellsc" + + def test_display_name_mentions_dell(self, backend): + """Test that display name mentions Dell.""" + assert "dell" in backend.display_name.lower() + + def test_charm_name_is_dellsc_charm(self, backend): + """Test that charm name is cinder-volume-dellsc.""" + assert backend.charm_name == "cinder-volume-dellsc" + + def test_dellsc_config_has_required_fields(self, backend): + """Test that Dell SC config has all required fields.""" + config_class = backend.config_type() + fields = config_class.model_fields + + # Verify Dell SC-specific required fields + required_fields = [ + "san_ip", + "san_username", + "san_password", + ] + for field in required_fields: + assert field in fields, f"Required field {field} not found in config" + + def test_dellsc_protocol_is_optional_literal(self, backend): + """Test that protocol field accepts fc or iscsi.""" + config_class = backend.config_type() + protocol_field = config_class.model_fields.get("protocol") + assert protocol_field is not None + + # Test config without protocol (optional) + config_no_protocol = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-username": "admin", + "san-password": "secret", + } + ) + assert config_no_protocol.protocol is None + + # Test valid config with fc + valid_config_fc = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-username": "admin", + "san-password": "secret", + "protocol": "fc", + } + ) + assert valid_config_fc.protocol == "fc" + + # Test valid config with iscsi + valid_config_iscsi = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-username": "admin", + "san-password": "secret", + "protocol": "iscsi", + } + ) + assert valid_config_iscsi.protocol == "iscsi" + + def test_dellsc_san_credentials_are_secret(self, backend): + """Test that SAN credentials are properly marked as secrets.""" + from sunbeam.storage.models import SecretDictField + + config_class = backend.config_type() + + # Check san_username is marked as secret + username_field = config_class.model_fields.get("san_username") + assert username_field is not None + has_secret_marker = any( + isinstance(m, SecretDictField) for m in username_field.metadata + ) + assert has_secret_marker, "san_username should be marked as secret" + + # Check san_password is marked as secret + password_field = config_class.model_fields.get("san_password") + assert password_field is not None + has_secret_marker = any( + isinstance(m, SecretDictField) for m in password_field.metadata + ) + assert has_secret_marker, "san_password should be marked as secret" + + def test_dellsc_secondary_credentials_are_secret(self, backend): + """Test that secondary SAN credentials are properly marked as secrets.""" + from sunbeam.storage.models import SecretDictField + + config_class = backend.config_type() + + # Check secondary_san_username is marked as secret + sec_user_field = config_class.model_fields.get("secondary_san_username") + assert sec_user_field is not None + has_secret_marker = any( + isinstance(m, SecretDictField) for m in sec_user_field.metadata + ) + assert has_secret_marker, "secondary_san_username should be marked as secret" + + # Check secondary_san_password is marked as secret + sec_pass_field = config_class.model_fields.get("secondary_san_password") + assert sec_pass_field is not None + has_secret_marker = any( + isinstance(m, SecretDictField) for m in sec_pass_field.metadata + ) + assert has_secret_marker, "secondary_san_password should be marked as secret" + + def test_dellsc_config_optional_fields_work(self, backend): + """Test that optional fields can be omitted.""" + config_class = backend.config_type() + + # Create config with only required fields + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-username": "admin", + "san-password": "secret", + } + ) + + # Verify optional fields default to None + assert config.dell_sc_ssn is None + assert config.protocol is None + assert config.volume_backend_name is None + assert config.backend_availability_zone is None + assert config.dell_sc_api_port is None + + def test_dellsc_dell_specific_fields_exist(self, backend): + """Test that Dell SC-specific fields exist.""" + config_class = backend.config_type() + fields = config_class.model_fields + + dell_specific_fields = [ + "dell_sc_ssn", + "dell_sc_api_port", + "dell_sc_server_folder", + "dell_sc_volume_folder", + "dell_server_os", + "dell_sc_verify_cert", + ] + for field in dell_specific_fields: + assert field in fields, f"Dell SC field {field} not found" + + def test_dellsc_dual_dsm_fields_exist(self, backend): + """Test that dual DSM configuration fields exist.""" + config_class = backend.config_type() + fields = config_class.model_fields + + dual_dsm_fields = [ + "secondary_san_ip", + "secondary_san_username", + "secondary_san_password", + "secondary_sc_api_port", + ] + for field in dual_dsm_fields: + assert field in fields, f"Dual DSM field {field} not found" + + def test_dellsc_network_filtering_fields_exist(self, backend): + """Test that network filtering fields exist.""" + config_class = backend.config_type() + fields = config_class.model_fields + + network_fields = [ + "excluded_domain_ips", + "included_domain_ips", + ] + for field in network_fields: + assert field in fields, f"Network filtering field {field} not found" + + def test_dellsc_ssh_fields_exist(self, backend): + """Test that SSH configuration fields exist.""" + config_class = backend.config_type() + fields = config_class.model_fields + + ssh_fields = [ + "ssh_conn_timeout", + "ssh_max_pool_conn", + "ssh_min_pool_conn", + ] + for field in ssh_fields: + assert field in fields, f"SSH field {field} not found" + + def test_dellsc_api_timeout_fields_exist(self, backend): + """Test that API timeout fields exist.""" + config_class = backend.config_type() + fields = config_class.model_fields + + timeout_fields = [ + "dell_api_async_rest_timeout", + "dell_api_sync_rest_timeout", + ] + for field in timeout_fields: + assert field in fields, f"API timeout field {field} not found" + + +class TestDellSCConfigValidation: + """Test Dell SC config validation behavior.""" + + def test_protocol_accepts_only_valid_values(self, dellsc_backend): + """Test that protocol field rejects invalid values.""" + from pydantic import ValidationError + + config_class = dellsc_backend.config_type() + + # Should reject invalid protocol + with pytest.raises(ValidationError) as exc_info: + config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-username": "admin", + "san-password": "secret", + "protocol": "INVALID", + } + ) + + assert "protocol" in str(exc_info.value).lower() + + def test_boolean_fields_accept_boolean_values(self, dellsc_backend): + """Test that boolean fields accept boolean values.""" + config_class = dellsc_backend.config_type() + + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-username": "admin", + "san-password": "secret", + "san-thin-provision": True, + "dell-sc-verify-cert": False, + } + ) + assert config.san_thin_provision is True + assert config.dell_sc_verify_cert is False + + def test_integer_fields_accept_integer_values(self, dellsc_backend): + """Test that integer fields accept integer values.""" + config_class = dellsc_backend.config_type() + + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-username": "admin", + "san-password": "secret", + "dell-sc-ssn": 12345, + "dell-sc-api-port": 3033, + "secondary-sc-api-port": 3033, + "dell-api-async-rest-timeout": 30, + "dell-api-sync-rest-timeout": 60, + } + ) + assert config.dell_sc_ssn == 12345 + assert config.dell_sc_api_port == 3033 + assert config.secondary_sc_api_port == 3033 + assert config.dell_api_async_rest_timeout == 30 + assert config.dell_api_sync_rest_timeout == 60 + + def test_ssh_pool_connection_values(self, dellsc_backend): + """Test that SSH pool connection values are accepted.""" + config_class = dellsc_backend.config_type() + + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-username": "admin", + "san-password": "secret", + "ssh-conn-timeout": 30, + "ssh-max-pool-conn": 5, + "ssh-min-pool-conn": 1, + } + ) + assert config.ssh_conn_timeout == 30 + assert config.ssh_max_pool_conn == 5 + assert config.ssh_min_pool_conn == 1 + + def test_dual_dsm_configuration(self, dellsc_backend): + """Test that dual DSM configuration works together.""" + config_class = dellsc_backend.config_type() + + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-username": "admin", + "san-password": "secret", + "secondary-san-ip": "192.168.1.2", + "secondary-san-username": "admin2", + "secondary-san-password": "secret2", + "secondary-sc-api-port": 3034, + } + ) + assert config.secondary_san_ip == "192.168.1.2" + assert config.secondary_san_username == "admin2" + assert config.secondary_san_password == "secret2" + assert config.secondary_sc_api_port == 3034 + + +if __name__ == "__main__": + # This allows running the file directly with pytest + pytest.main([__file__, "-v"]) diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/test_hitachi.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_hitachi.py new file mode 100644 index 000000000..dbf72e674 --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_hitachi.py @@ -0,0 +1,244 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for Hitachi VSP storage backend.""" + +import pytest + +from tests.unit.sunbeam.storage.backends.test_common import BaseBackendTests + + +class TestHitachiBackend(BaseBackendTests): + """Tests for Hitachi VSP storage backend. + + Inherits all generic tests from BaseBackendTests and adds + backend-specific tests. + """ + + @pytest.fixture + def backend(self, hitachi_backend): + """Provide Hitachi backend instance.""" + return hitachi_backend + + # Backend-specific tests + + def test_backend_type_is_hitachi(self, backend): + """Test that backend type is 'hitachi'.""" + assert backend.backend_type == "hitachi" + + def test_display_name_mentions_hitachi(self, backend): + """Test that display name mentions Hitachi.""" + assert "hitachi" in backend.display_name.lower() + + def test_charm_name_is_hitachi_charm(self, backend): + """Test that charm name is cinder-volume-hitachi.""" + assert backend.charm_name == "cinder-volume-hitachi" + + def test_hitachi_config_has_required_fields(self, backend): + """Test that Hitachi config has all required fields.""" + config_class = backend.config_type() + fields = config_class.model_fields + + # Verify Hitachi-specific required fields + required_fields = [ + "hitachi_storage_id", + "hitachi_pools", + "san_ip", + "san_username", + "san_password", + "protocol", + ] + for field in required_fields: + assert field in fields, f"Required field {field} not found in config" + + def test_hitachi_protocol_is_literal(self, backend): + """Test that protocol field only accepts FC or iSCSI.""" + config_class = backend.config_type() + protocol_field = config_class.model_fields.get("protocol") + assert protocol_field is not None + + # Test valid config with FC + valid_config_fc = config_class.model_validate( + { + "hitachi-storage-id": "12345", + "hitachi-pools": "pool1", + "san-ip": "192.168.1.1", + "san-username": "admin", + "san-password": "secret", + "protocol": "FC", + } + ) + assert valid_config_fc.protocol == "FC" + + # Test valid config with iSCSI + valid_config_iscsi = config_class.model_validate( + { + "hitachi-storage-id": "12345", + "hitachi-pools": "pool1", + "san-ip": "192.168.1.1", + "san-username": "admin", + "san-password": "secret", + "protocol": "iSCSI", + } + ) + assert valid_config_iscsi.protocol == "iSCSI" + + def test_hitachi_san_credentials_are_secret(self, backend): + """Test that SAN credentials are properly marked as secrets.""" + from sunbeam.storage.models import SecretDictField + + config_class = backend.config_type() + + # Check san_username is marked as secret + username_field = config_class.model_fields.get("san_username") + assert username_field is not None + has_secret_marker = any( + isinstance(m, SecretDictField) for m in username_field.metadata + ) + assert has_secret_marker, "san_username should be marked as secret" + + # Check san_password is marked as secret + password_field = config_class.model_fields.get("san_password") + assert password_field is not None + has_secret_marker = any( + isinstance(m, SecretDictField) for m in password_field.metadata + ) + assert has_secret_marker, "san_password should be marked as secret" + + def test_hitachi_chap_credentials_are_secret(self, backend): + """Test that CHAP credentials are properly marked as secrets.""" + from sunbeam.storage.models import SecretDictField + + config_class = backend.config_type() + + # Check chap_username is marked as secret + chap_user_field = config_class.model_fields.get("chap_username") + assert chap_user_field is not None + has_secret_marker = any( + isinstance(m, SecretDictField) for m in chap_user_field.metadata + ) + assert has_secret_marker, "chap_username should be marked as secret" + + # Check chap_password is marked as secret + chap_pass_field = config_class.model_fields.get("chap_password") + assert chap_pass_field is not None + has_secret_marker = any( + isinstance(m, SecretDictField) for m in chap_pass_field.metadata + ) + assert has_secret_marker, "chap_password should be marked as secret" + + def test_hitachi_config_optional_fields_work(self, backend): + """Test that optional fields can be omitted.""" + config_class = backend.config_type() + + # Create config with only required fields + config = config_class.model_validate( + { + "hitachi-storage-id": "12345", + "hitachi-pools": "pool1", + "san-ip": "192.168.1.1", + "san-username": "admin", + "san-password": "secret", + "protocol": "FC", + } + ) + + # Verify optional fields default to None + assert config.volume_backend_name is None + assert config.backend_availability_zone is None + assert config.hitachi_target_ports is None + assert config.hitachi_copy_speed is None + + def test_hitachi_mirror_rest_credentials_are_secret(self, backend): + """Test that mirror REST credentials are properly marked as secrets.""" + from sunbeam.storage.models import SecretDictField + + config_class = backend.config_type() + + # Check mirror REST username is marked as secret + mirror_user_field = config_class.model_fields.get( + "hitachi_mirror_rest_username" + ) + assert mirror_user_field is not None + has_secret_marker = any( + isinstance(m, SecretDictField) for m in mirror_user_field.metadata + ) + assert has_secret_marker, ( + "hitachi_mirror_rest_username should be marked as secret" + ) + + # Check mirror REST password is marked as secret + mirror_pass_field = config_class.model_fields.get( + "hitachi_mirror_rest_password" + ) + assert mirror_pass_field is not None + has_secret_marker = any( + isinstance(m, SecretDictField) for m in mirror_pass_field.metadata + ) + assert has_secret_marker, ( + "hitachi_mirror_rest_password should be marked as secret" + ) + + +class TestHitachiConfigValidation: + """Test Hitachi config validation behavior.""" + + def test_protocol_accepts_only_valid_values(self, hitachi_backend): + """Test that protocol field rejects invalid values.""" + from pydantic import ValidationError + + config_class = hitachi_backend.config_type() + + # Should reject invalid protocol + with pytest.raises(ValidationError) as exc_info: + config_class.model_validate( + { + "hitachi-storage-id": "12345", + "hitachi-pools": "pool1", + "san-ip": "192.168.1.1", + "san-username": "admin", + "san-password": "secret", + "protocol": "INVALID", + } + ) + + assert "protocol" in str(exc_info.value).lower() + + def test_copy_speed_validates_range(self, hitachi_backend): + """Test that copy_speed validates range (1-15) if configured.""" + config_class = hitachi_backend.config_type() + + # Valid copy speed + config = config_class.model_validate( + { + "hitachi-storage-id": "12345", + "hitachi-pools": "pool1", + "san-ip": "192.168.1.1", + "san-username": "admin", + "san-password": "secret", + "protocol": "FC", + "hitachi-copy-speed": 10, + } + ) + assert config.hitachi_copy_speed == 10 + + def test_boolean_fields_accept_boolean_values(self, hitachi_backend): + """Test that boolean fields accept boolean values.""" + config_class = hitachi_backend.config_type() + + config = config_class.model_validate( + { + "hitachi-storage-id": "12345", + "hitachi-pools": "pool1", + "san-ip": "192.168.1.1", + "san-username": "admin", + "san-password": "secret", + "protocol": "FC", + "use-chap-auth": True, + "hitachi-discard-zero-page": False, + "hitachi-group-create": True, + } + ) + assert config.use_chap_auth is True + assert config.hitachi_discard_zero_page is False + assert config.hitachi_group_create is True diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/test_purestorage.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_purestorage.py new file mode 100644 index 000000000..5b92a0828 --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_purestorage.py @@ -0,0 +1,269 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for Pure Storage FlashArray backend.""" + +import pytest + +from tests.unit.sunbeam.storage.backends.test_common import BaseBackendTests + + +class TestPureStorageBackend(BaseBackendTests): + """Tests for Pure Storage FlashArray backend. + + Inherits all generic tests from BaseBackendTests and adds + backend-specific tests. + """ + + @pytest.fixture + def backend(self, purestorage_backend): + """Provide Pure Storage backend instance.""" + return purestorage_backend + + # Backend-specific tests + + def test_backend_type_is_purestorage(self, backend): + """Test that backend type is 'purestorage'.""" + assert backend.backend_type == "purestorage" + + def test_display_name_mentions_pure(self, backend): + """Test that display name mentions Pure Storage.""" + assert "pure" in backend.display_name.lower() + + def test_charm_name_is_purestorage_charm(self, backend): + """Test that charm name is cinder-volume-purestorage.""" + assert backend.charm_name == "cinder-volume-purestorage" + + def test_purestorage_config_has_required_fields(self, backend): + """Test that Pure Storage config has all required fields.""" + config_class = backend.config_type() + fields = config_class.model_fields + + # Verify Pure Storage-specific required fields + required_fields = [ + "san_ip", + "pure_api_token", + ] + for field in required_fields: + assert field in fields, f"Required field {field} not found in config" + + def test_purestorage_protocol_is_optional_literal(self, backend): + """Test that protocol field accepts iscsi, fc, or nvme.""" + config_class = backend.config_type() + protocol_field = config_class.model_fields.get("protocol") + assert protocol_field is not None + + # Test config without protocol (optional) + config_no_protocol = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "pure-api-token": "secret-token", + } + ) + assert config_no_protocol.protocol is None + + # Test valid config with iscsi + valid_config_iscsi = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "pure-api-token": "secret-token", + "protocol": "iscsi", + } + ) + assert valid_config_iscsi.protocol == "iscsi" + + # Test valid config with fc + valid_config_fc = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "pure-api-token": "secret-token", + "protocol": "fc", + } + ) + assert valid_config_fc.protocol == "fc" + + # Test valid config with nvme + valid_config_nvme = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "pure-api-token": "secret-token", + "protocol": "nvme", + } + ) + assert valid_config_nvme.protocol == "nvme" + + def test_purestorage_api_token_is_secret(self, backend): + """Test that API token is properly marked as secret.""" + from sunbeam.storage.models import SecretDictField + + config_class = backend.config_type() + + # Check pure_api_token is marked as secret + token_field = config_class.model_fields.get("pure_api_token") + assert token_field is not None + has_secret_marker = any( + isinstance(m, SecretDictField) for m in token_field.metadata + ) + assert has_secret_marker, "pure_api_token should be marked as secret" + + def test_purestorage_config_optional_fields_work(self, backend): + """Test that optional fields can be omitted.""" + config_class = backend.config_type() + + # Create config with only required fields + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "pure-api-token": "secret-token", + } + ) + + # Verify optional fields default to None + assert config.protocol is None + assert config.pure_iscsi_cidr is None + assert config.pure_nvme_cidr is None + assert config.pure_host_personality is None + assert config.pure_eradicate_on_delete is None + + def test_purestorage_personality_enum(self, backend): + """Test that host personality accepts valid enum values.""" + config_class = backend.config_type() + + # Test with valid personality + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "pure-api-token": "secret-token", + "pure-host-personality": "esxi", + } + ) + assert config.pure_host_personality == "esxi" + + def test_purestorage_replication_fields_exist(self, backend): + """Test that replication-related fields exist.""" + config_class = backend.config_type() + fields = config_class.model_fields + + replication_fields = [ + "pure_replica_interval_default", + "pure_replica_retention_short_term_default", + "pure_replication_pg_name", + "pure_replication_pod_name", + "pure_trisync_enabled", + ] + for field in replication_fields: + assert field in fields, f"Replication field {field} not found" + + def test_purestorage_iscsi_fields_exist(self, backend): + """Test that iSCSI-related fields exist.""" + config_class = backend.config_type() + fields = config_class.model_fields + + iscsi_fields = [ + "pure_iscsi_cidr", + "pure_iscsi_cidr_list", + ] + for field in iscsi_fields: + assert field in fields, f"iSCSI field {field} not found" + + def test_purestorage_nvme_fields_exist(self, backend): + """Test that NVMe-related fields exist.""" + config_class = backend.config_type() + fields = config_class.model_fields + + nvme_fields = [ + "pure_nvme_cidr", + "pure_nvme_cidr_list", + "pure_nvme_transport", + ] + for field in nvme_fields: + assert field in fields, f"NVMe field {field} not found" + + +class TestPureStorageConfigValidation: + """Test Pure Storage config validation behavior.""" + + def test_protocol_accepts_only_valid_values(self, purestorage_backend): + """Test that protocol field rejects invalid values.""" + from pydantic import ValidationError + + config_class = purestorage_backend.config_type() + + # Should reject invalid protocol + with pytest.raises(ValidationError) as exc_info: + config_class.model_validate( + { + "san-ip": "192.168.1.1", + "pure-api-token": "secret-token", + "protocol": "INVALID", + } + ) + + assert "protocol" in str(exc_info.value).lower() + + def test_nvme_transport_accepts_only_tcp(self, purestorage_backend): + """Test that NVMe transport only accepts tcp.""" + from pydantic import ValidationError + + config_class = purestorage_backend.config_type() + + # Valid transport + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "pure-api-token": "secret-token", + "pure-nvme-transport": "tcp", + } + ) + assert config.pure_nvme_transport == "tcp" + + # Should reject invalid transport + with pytest.raises(ValidationError) as exc_info: + config_class.model_validate( + { + "san-ip": "192.168.1.1", + "pure-api-token": "secret-token", + "pure-nvme-transport": "roce", # Not supported yet + } + ) + + assert ( + "pure_nvme_transport" in str(exc_info.value).lower() + or "nvme" in str(exc_info.value).lower() + ) + + def test_boolean_fields_accept_boolean_values(self, purestorage_backend): + """Test that boolean fields accept boolean values.""" + config_class = purestorage_backend.config_type() + + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "pure-api-token": "secret-token", + "pure-automatic-max-oversubscription-ratio": True, + "pure-eradicate-on-delete": False, + "pure-trisync-enabled": True, + } + ) + assert config.pure_automatic_max_oversubscription_ratio is True + assert config.pure_eradicate_on_delete is False + assert config.pure_trisync_enabled is True + + def test_integer_fields_accept_integer_values(self, purestorage_backend): + """Test that integer fields accept integer values.""" + config_class = purestorage_backend.config_type() + + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "pure-api-token": "secret-token", + "pure-replica-interval-default": 3600, + "pure-replica-retention-short-term-default": 86400, + "pure-replica-retention-long-term-per-day-default": 3, + "pure-replica-retention-long-term-default": 7, + } + ) + assert config.pure_replica_interval_default == 3600 + assert config.pure_replica_retention_short_term_default == 86400 + assert config.pure_replica_retention_long_term_per_day_default == 3 + assert config.pure_replica_retention_long_term_default == 7 diff --git a/sunbeam-python/tests/unit/sunbeam/storage/conftest.py b/sunbeam-python/tests/unit/sunbeam/storage/conftest.py index 00b2c0996..edc15c579 100644 --- a/sunbeam-python/tests/unit/sunbeam/storage/conftest.py +++ b/sunbeam-python/tests/unit/sunbeam/storage/conftest.py @@ -1,275 +1,145 @@ # SPDX-FileCopyrightText: 2025 - Canonical Ltd # SPDX-License-Identifier: Apache-2.0 -"""Test fixtures for storage backend tests.""" +"""Common fixtures for storage backend tests.""" -from unittest.mock import Mock, PropertyMock +from pathlib import Path +from typing import Annotated +from unittest.mock import MagicMock import pytest +from pydantic import Field -from sunbeam.clusterd.client import Client -from sunbeam.core.manifest import Manifest -from sunbeam.core.terraform import TerraformHelper +from sunbeam.core.manifest import StorageBackendConfig from sunbeam.storage.base import StorageBackendBase -from sunbeam.storage.models import StorageBackendConfig +from sunbeam.storage.models import SecretDictField -@pytest.fixture -def mock_deployment(): - """Mock deployment object.""" - deployment = Mock() - deployment.openstack_machines_model = "openstack" # Match actual service behavior - - # Mock the client with cluster attribute - mock_client = Mock() - mock_cluster = Mock() - mock_client.cluster = mock_cluster - deployment.get_client.return_value = mock_client +class MockStorageConfig(StorageBackendConfig): + """Mock configuration for testing.""" - # Mock juju_controller - mock_controller = Mock() - mock_controller.name = "test-controller" - type(deployment).juju_controller = PropertyMock(return_value=mock_controller) + required_field: Annotated[str, Field(description="A required field")] + optional_field: Annotated[str | None, Field(description="An optional field")] = None + secret_field: Annotated[ + str, + Field(description="A secret field"), + SecretDictField(field="secret-key"), + ] + int_field: Annotated[int | None, Field(description="An integer field")] = None - # Mock get_tfhelper - deployment.get_tfhelper.return_value = Mock(spec=TerraformHelper) - return deployment +class MockStorageBackend(StorageBackendBase[MockStorageConfig]): + """Mock storage backend for testing.""" + + backend_type = "mock" + display_name = "Mock Storage Backend" + + @property + def charm_name(self) -> str: + return "mock-charm" + + @property + def charm_channel(self) -> str: + return "latest/stable" + + @property + def backend_endpoint(self) -> str: + return "cinder-volume" + + def config_type(self) -> type[MockStorageConfig]: + return MockStorageConfig @pytest.fixture -def mock_client(): - """Mock clusterd client.""" - client = Mock(spec=Client) - client.cluster = Mock() - return client +def mock_backend(): + """Create a mock backend instance for testing.""" + return MockStorageBackend() @pytest.fixture -def mock_tfhelper(): - """Mock Terraform helper.""" - return Mock(spec=TerraformHelper) +def mock_deployment(tmp_path: Path): + """Create a mock deployment object.""" + deployment = MagicMock() + deployment.name = "test_deployment" + deployment.plans_directory = tmp_path / "plans" + deployment.plans_directory.mkdir(parents=True) + deployment.openstack_machines_model = "openstack" + deployment.juju_controller = "test-controller" + + # Mock get_space method + deployment.get_space.return_value = "test-space" + + # Mock get_client + mock_client = MagicMock() + deployment.get_client.return_value = mock_client + + # Mock get_tfhelper + mock_tfhelper = MagicMock() + deployment.get_tfhelper.return_value = mock_tfhelper + + # Mock proxy settings + deployment.get_proxy_settings.return_value = {} + + # Mock _get_juju_clusterd_env + deployment._get_juju_clusterd_env.return_value = {} + + # Mock get_clusterd_http_address + deployment.get_clusterd_http_address.return_value = "http://localhost:7000" + + # Mock _tfhelpers + deployment._tfhelpers = {} + + return deployment @pytest.fixture def mock_jhelper(): - """Mock Juju helper.""" - return Mock() + """Create a mock JujuHelper.""" + jhelper = MagicMock() + jhelper.get_model_name_with_owner.return_value = "admin/openstack" + + # Mock model status + mock_status = MagicMock() + mock_status.apps = {} + jhelper.get_model_status.return_value = mock_status + + # Mock get_model + jhelper.get_model.return_value = {"model-uuid": "test-uuid"} + + return jhelper @pytest.fixture def mock_manifest(): - """Mock manifest object.""" - return Mock(spec=Manifest) + """Create a mock manifest.""" + manifest = MagicMock() + manifest.storage.root = {} + return manifest @pytest.fixture -def sample_backend_config(): - """Sample backend configuration for testing.""" - return { - "model": "openstack", - "hitachi_backends": { - "test-backend": { - "backend_type": "hitachi", - "charm_name": "cinder-volume-hitachi", - "charm_channel": "latest/edge", - "backend_config": { - "hitachi-storage-id": "123456", - "hitachi-pools": ["pool1", "pool2"], - "san-ip": "192.168.1.100", - }, - "backend_endpoint": "cinder-volume", - "units": 1, - "additional_integrations": {}, - } - }, - } +def mock_console(): + """Create a mock console.""" + return MagicMock() @pytest.fixture -def sample_clusterd_config(): - """Sample clusterd configuration for testing.""" - return { - "TerraformVarsStorageBackends": { - "hitachi_backends": { - "test-backend": { - "backend_type": "hitachi", - "charm_name": "cinder-volume-hitachi", - "charm_channel": "latest/edge", - "charm_config": { - "hitachi-storage-id": "123456", - "hitachi-pools": ["pool1", "pool2"], - "san-ip": "192.168.1.100", - }, - "backend_endpoint": "cinder-volume", - "units": 1, - "additional_integrations": {}, - } - } - } - } +def terraform_plan_dir(tmp_path: Path): + """Create a temporary terraform plan directory.""" + plan_dir = tmp_path / "etc" / "deploy-storage" + plan_dir.mkdir(parents=True) + # Create some dummy terraform files + (plan_dir / "main.tf").write_text("# Terraform config") + (plan_dir / "variables.tf").write_text("# Variables") -@pytest.fixture -def mock_storage_backend(): - """Mock storage backend for testing.""" + return plan_dir - class MockStorageBackend(StorageBackendBase): - """Mock storage backend for testing.""" - - name = "mock" - display_name = "Mock Storage Backend" - charm_name = "mock-charm" - - def __init__(self): - super().__init__() - self.tfplan = "mock-backend-plan" - self.tfplan_dir = "deploy-mock-backend" - - @property - def config_class(self): - return StorageBackendConfig - - def get_terraform_variables( - self, backend_name: str, config: StorageBackendConfig, model: str - ): - return { - "model": model, - "mock_backends": { - backend_name: { - "backend_type": self.name, - "charm_name": self.charm_name, - "charm_channel": "latest/stable", - "backend_config": config.model_dump(), - "backend_endpoint": "storage-backend", - "units": 1, - "additional_integrations": {}, - } - }, - } - - def create_deploy_step( - self, - deployment, - client, - tfhelper, - jhelper, - manifest, - backend_name, - backend_config, - model, - ): - """Create a mock deploy step.""" - from sunbeam.storage.steps import BaseStorageBackendDeployStep - - class MockDeployStep(BaseStorageBackendDeployStep): - def get_terraform_variables(self): - return self.backend_instance.get_terraform_variables( - self.backend_name, self.backend_config, self.model - ) - - return MockDeployStep( - deployment, - client, - tfhelper, - jhelper, - manifest, - backend_name, - backend_config, - self, - model, - ) - - def create_destroy_step( - self, deployment, client, tfhelper, jhelper, manifest, backend_name, model - ): - """Create a mock destroy step.""" - from sunbeam.storage.steps import BaseStorageBackendDestroyStep - - return BaseStorageBackendDestroyStep( - deployment, - client, - tfhelper, - jhelper, - manifest, - backend_name, - self, - model, - ) - - def create_config_update_step( - self, - deployment, - client, - tfhelper, - jhelper, - manifest, - backend_name, - backend_config, - model, - updates, - ): - """Create a mock config update step.""" - from sunbeam.storage.steps import BaseStorageBackendConfigUpdateStep - - return BaseStorageBackendConfigUpdateStep( - deployment, - client, - tfhelper, - jhelper, - manifest, - backend_name, - backend_config, - self, - model, - updates, - ) - - def create_update_config_step(self, deployment, backend_name, config_updates): - """Create a configuration update step for this backend.""" - # Mock implementation - return a simple mock step - - from sunbeam.core.common import BaseStep - - class MockUpdateConfigStep(BaseStep): - def run(self): - from sunbeam.core.common import ResultType - - return ResultType.COMPLETED - - return MockUpdateConfigStep() - - def commands(self): - """Return mock commands for testing.""" - return { - "add": [{"name": "mock", "command": Mock()}], - "remove": [{"name": "mock", "command": Mock()}], - "list": [{"name": "mock", "command": Mock()}], - "config": [{"name": "mock", "command": Mock()}], - } - - def register_add_cli(self, add): - """Mock CLI registration.""" - pass - - def register_cli( - self, - remove, - config_show, - config_set, - config_reset, - config_options, - deployment, - ): - """Mock CLI registration.""" - pass - - def prompt_for_config(self, backend_name: str) -> StorageBackendConfig: - """Mock prompt for configuration.""" - return StorageBackendConfig(name=backend_name) - - def _get_default_config(self) -> StorageBackendConfig: - """Get a default configuration instance for comparison.""" - return StorageBackendConfig(name="default") - return MockStorageBackend() +@pytest.fixture +def mock_click_context(mock_deployment, mock_manifest): + """Create a mock Click context.""" + ctx = MagicMock() + ctx.obj = mock_deployment + mock_deployment.get_manifest.return_value = mock_manifest + return ctx diff --git a/sunbeam-python/tests/unit/sunbeam/storage/test_base.py b/sunbeam-python/tests/unit/sunbeam/storage/test_base.py new file mode 100644 index 000000000..8b470d908 --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/test_base.py @@ -0,0 +1,522 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for StorageBackendBase class. + +These tests are designed to be generic and can be reused by child classes +by overriding the backend fixture. +""" + +from unittest.mock import Mock, patch + +import click +import pytest +from packaging.version import Version + +from sunbeam.core.manifest import StorageBackendConfig +from sunbeam.storage.base import ( + FQDN_PATTERN, + JUJU_APP_NAME_PATTERN, + validate_juju_application_name, +) +from sunbeam.storage.models import ( + BackendAlreadyExistsException, +) + + +class TestJujuApplicationNameValidation: + """Test Juju application name validation logic.""" + + def test_valid_names(self): + """Test valid Juju application names.""" + valid_names = [ + "myapp", + "my-app", + "my-app-backend", + "a", + "a1", + "app123", + "my-storage", + ] + for name in valid_names: + assert validate_juju_application_name(name), f"{name} should be valid" + + def test_invalid_names(self): + """Test invalid Juju application names.""" + invalid_names = [ + "", # Empty + "MyApp", # Uppercase + "my_app", # Underscore + "123app", # Starts with number + "-myapp", # Starts with hyphen + "myapp-", # Ends with hyphen + "my--app", # Consecutive hyphens + "my-app-1", # Number after final hyphen + ] + for name in invalid_names: + assert not validate_juju_application_name(name), f"{name} should be invalid" + + def test_pattern_matching(self): + """Test the regex pattern directly.""" + assert JUJU_APP_NAME_PATTERN.match("myapp") + assert not JUJU_APP_NAME_PATTERN.match("MyApp") + assert not JUJU_APP_NAME_PATTERN.match("123app") + + +class TestFQDNPattern: + """Test FQDN pattern validation.""" + + def test_valid_fqdns(self): + """Test valid FQDNs.""" + import re + + valid_fqdns = [ + "example.com", + "sub.example.com", + "my-server.example.com", + "server1.example.com", + "a.b.c.d.example.com", + ] + pattern = re.compile(FQDN_PATTERN) + for fqdn in valid_fqdns: + assert pattern.match(fqdn), f"{fqdn} should be valid" + + def test_invalid_fqdns(self): + """Test invalid FQDNs.""" + import re + + invalid_fqdns = [ + "", + "-example.com", + "example-.com", + "exa mple.com", + "example..com", + ] + pattern = re.compile(FQDN_PATTERN) + for fqdn in invalid_fqdns: + assert not pattern.match(fqdn), f"{fqdn} should be invalid" + + +class BaseStorageBackendTests: + """Base test class for storage backends. + + Subclasses can inherit from this to get comprehensive testing + for their backend implementations. + """ + + @pytest.fixture + def backend(self, mock_backend): + """Override this fixture to test a specific backend.""" + return mock_backend + + def test_backend_type_is_set(self, backend): + """Test that backend_type is set.""" + assert backend.backend_type + assert isinstance(backend.backend_type, str) + assert backend.backend_type != "base" + + def test_display_name_is_set(self, backend): + """Test that display_name is set.""" + assert backend.display_name + assert isinstance(backend.display_name, str) + + def test_version_is_set(self, backend): + """Test that version is set.""" + assert backend.version + assert isinstance(backend.version, Version) + + def test_charm_name_is_set(self, backend): + """Test that charm_name is set.""" + assert backend.charm_name + assert isinstance(backend.charm_name, str) + + def test_charm_channel_is_set(self, backend): + """Test that charm_channel is set.""" + assert backend.charm_channel + assert isinstance(backend.charm_channel, str) + + def test_charm_base_is_set(self, backend): + """Test that charm_base is set.""" + assert backend.charm_base + assert isinstance(backend.charm_base, str) + + def test_backend_endpoint_is_set(self, backend): + """Test that backend_endpoint is set.""" + assert backend.backend_endpoint + assert isinstance(backend.backend_endpoint, str) + + def test_principal_application_is_set(self, backend): + """Test that principal_application is set.""" + assert backend.principal_application + assert isinstance(backend.principal_application, str) + + def test_units_is_positive(self, backend): + """Test that units is a positive integer.""" + assert backend.units > 0 + assert isinstance(backend.units, int) + + def test_tfplan_properties(self, backend): + """Test Terraform plan properties.""" + assert backend.tfplan + assert backend.tfplan_dir + assert isinstance(backend.tfplan, str) + assert isinstance(backend.tfplan_dir, str) + + def test_tfvar_config_key(self, backend): + """Test tfvar config key.""" + assert backend.tfvar_config_key == "TerraformVarsStorageBackends" + + def test_config_key(self, backend): + """Test config key generation.""" + name = "test-backend" + key = backend.config_key(name) + assert key == f"Storage-{name}" + + def test_config_type_returns_pydantic_model(self, backend): + """Test that config_type returns a Pydantic model class.""" + config_class = backend.config_type() + assert issubclass(config_class, StorageBackendConfig) + + def test_get_endpoint_bindings(self, backend, mock_deployment): + """Test endpoint bindings generation.""" + bindings = backend.get_endpoint_bindings(mock_deployment) + assert isinstance(bindings, list) + assert len(bindings) >= 1 + for binding in bindings: + assert isinstance(binding, dict) + + def test_validate_ip_or_fqdn_with_valid_ip(self, backend): + """Test IP validation with valid IPs.""" + valid_ips = ["192.168.1.1", "10.0.0.1", "2001:db8::1"] + for ip in valid_ips: + assert backend._validate_ip_or_fqdn(ip) == ip + + def test_validate_ip_or_fqdn_with_valid_fqdn(self, backend): + """Test FQDN validation with valid FQDNs.""" + valid_fqdns = ["example.com", "server.example.com", "my-server.local"] + for fqdn in valid_fqdns: + assert backend._validate_ip_or_fqdn(fqdn) == fqdn + + def test_validate_ip_or_fqdn_with_invalid_value(self, backend): + """Test IP/FQDN validation with invalid values.""" + invalid_values = ["not an ip", "example..com", "", "-invalid.com"] + for value in invalid_values: + with pytest.raises(click.BadParameter): + backend._validate_ip_or_fqdn(value) + + +class TestStorageBackendBase(BaseStorageBackendTests): + """Tests for the base StorageBackendBase class using mock backend.""" + + def test_register_terraform_plan(self, backend, mock_deployment, tmp_path): + """Test Terraform plan registration raises error when plan not found.""" + # Mock the deployment's plan directory + mock_deployment.plans_directory = tmp_path / "plans" + mock_deployment.plans_directory.mkdir(parents=True, exist_ok=True) + + # Without a valid plan source, should raise FileNotFoundError + with pytest.raises(FileNotFoundError): + backend.register_terraform_plan(mock_deployment) + + def test_add_backend_instance_success( + self, backend, mock_deployment, mock_console, tmp_path + ): + """Test adding a backend instance successfully.""" + # Setup + backend_name = "test-backend" + config = {"required_field": "value", "secret_field": "secret"} + + # Mock the manifest property + mock_manifest = Mock() + mock_manifest.storage.root = {} + + # Mock the service and JujuHelper + with patch("sunbeam.storage.base.StorageBackendService") as mock_service_class: + with patch("sunbeam.storage.base.JujuHelper") as mock_jhelper_class: + # Patch the manifest property without accessing it + with patch.object( + type(backend), + "manifest", + new_callable=lambda: property(lambda self: mock_manifest), + ): + mock_service = Mock() + mock_service.backend_exists.return_value = False + mock_service_class.return_value = mock_service + + mock_jhelper = Mock() + mock_jhelper_class.return_value = mock_jhelper + + # Mock register_terraform_plan + with patch.object(backend, "register_terraform_plan"): + # Mock run_plan + with patch("sunbeam.storage.base.run_plan"): + backend.add_backend_instance( + mock_deployment, backend_name, config, mock_console + ) + + def test_add_backend_instance_invalid_name( + self, backend, mock_deployment, mock_console + ): + """Test adding a backend with invalid name.""" + invalid_names = ["MyApp", "app_name", "123app", "app-"] + + for invalid_name in invalid_names: + with pytest.raises(click.ClickException) as exc_info: + backend.add_backend_instance( + mock_deployment, invalid_name, {}, mock_console + ) + assert "Invalid backend name" in str(exc_info.value) + + def test_add_backend_instance_already_exists( + self, backend, mock_deployment, mock_console + ): + """Test adding a backend that already exists.""" + backend_name = "existing-backend" + config = {} + + with patch("sunbeam.storage.base.StorageBackendService") as mock_service_class: + with patch("sunbeam.storage.base.JujuHelper") as mock_jhelper_class: + mock_service = Mock() + mock_service.backend_exists.return_value = True + mock_service_class.return_value = mock_service + + mock_jhelper = Mock() + mock_jhelper_class.return_value = mock_jhelper + + with pytest.raises(BackendAlreadyExistsException): + backend.add_backend_instance( + mock_deployment, backend_name, config, mock_console + ) + + def test_remove_backend(self, backend, mock_deployment, mock_console, tmp_path): + """Test removing a backend.""" + backend_name = "test-backend" + + # Mock the manifest property + mock_manifest = Mock() + mock_manifest.storage.root = {} + + # Mock the plan directory + mock_deployment.plans_directory = tmp_path / "plans" + mock_deployment.plans_directory.mkdir(parents=True, exist_ok=True) + + with patch.object( + type(backend), + "manifest", + new_callable=lambda: property(lambda self: mock_manifest), + ): + with patch("sunbeam.storage.base.JujuHelper") as mock_jhelper_class: + mock_jhelper = Mock() + mock_jhelper_class.return_value = mock_jhelper + + with patch.object(backend, "register_terraform_plan"): + with patch("sunbeam.storage.base.run_plan"): + backend.remove_backend( + mock_deployment, backend_name, mock_console + ) + + def test_build_terraform_vars(self, backend, mock_deployment, mock_manifest): + """Test Terraform variables generation.""" + backend_name = "test-backend" + config = backend.config_type().model_validate( + { + "required-field": "test", + "secret-field": "secret123", + } + ) + + tfvars = backend.build_terraform_vars( + mock_deployment, mock_manifest, backend_name, config + ) + + assert "principal_application" in tfvars + assert tfvars["principal_application"] == backend.principal_application + assert "charm_name" in tfvars + assert tfvars["charm_name"] == backend.charm_name + assert "charm_channel" in tfvars + assert "charm_base" in tfvars + assert "endpoint_bindings" in tfvars + assert "charm_config" in tfvars + assert "secrets" in tfvars + + def test_display_config_options(self, backend, mock_console): + """Test display of configuration options.""" + with patch("sunbeam.storage.base.console", mock_console): + backend.display_config_options() + # Verify console.print was called + assert mock_console.print.called + + def test_display_config_table(self, backend, mock_console): + """Test display of configuration table.""" + backend_name = "test-backend" + config = backend.config_type().model_validate( + { + "required-field": "test_value", + "secret-field": "secret123", + "optional-field": "optional_value", + } + ) + + with patch("sunbeam.storage.base.console", mock_console): + backend.display_config_table(backend_name, config) + # Verify console.print was called + assert mock_console.print.called + + def test_display_config_table_with_empty_config(self, backend, mock_console): + """Test display of empty configuration.""" + backend_name = "test-backend" + config = None + + with patch("sunbeam.storage.base.console", mock_console): + backend.display_config_table(backend_name, config) + # Should still print something + assert mock_console.print.called + + def test_format_config_value_non_secret(self, backend): + """Test formatting of non-secret configuration values.""" + value = "test_value" + formatted = backend._format_config_value(value, is_secret=False) + assert formatted == "test_value" + + def test_format_config_value_secret(self, backend): + """Test formatting of secret configuration values.""" + value = "secret123" + formatted = backend._format_config_value(value, is_secret=True) + assert formatted == "********" + + def test_format_config_value_long(self, backend): + """Test formatting of long configuration values.""" + value = "a" * 30 + formatted = backend._format_config_value(value, is_secret=False) + assert formatted.endswith("...") + assert len(formatted) == 23 + + def test_field_is_secret(self, backend): + """Test detection of secret fields.""" + from sunbeam.storage.models import SecretDictField + + config_class = backend.config_type() + for field_name, field_info in config_class.model_fields.items(): + is_secret = backend._field_is_secret(field_info) + if field_name == "secret_field": + assert is_secret + else: + # Check if it's actually marked as secret + has_secret_metadata = any( + isinstance(m, SecretDictField) for m in field_info.metadata + ) + assert is_secret == has_secret_metadata + + def test_get_field_descriptions(self, backend): + """Test extraction of field descriptions.""" + config_class = backend.config_type() + descriptions = backend._get_field_descriptions(config_class) + + assert isinstance(descriptions, dict) + for field_name in config_class.model_fields.keys(): + assert field_name in descriptions + assert isinstance(descriptions[field_name], str) + + def test_extract_field_info(self, backend): + """Test extraction of field information.""" + config_class = backend.config_type() + for field_name, field_info in config_class.model_fields.items(): + field_type, description = backend._extract_field_info(field_info) + assert isinstance(field_type, str) + assert isinstance(description, str) + + def test_create_deploy_step(self, backend, mock_deployment, mock_jhelper): + """Test creation of deploy step.""" + from sunbeam.storage.steps import BaseStorageBackendDeployStep + + mock_client = Mock() + mock_tfhelper = Mock() + mock_manifest = Mock() + preseed = {} + backend_name = "test-backend" + model = "openstack" + + step = backend.create_deploy_step( + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + preseed, + backend_name, + model, + ) + + assert isinstance(step, BaseStorageBackendDeployStep) + + def test_create_destroy_step(self, backend, mock_deployment, mock_jhelper): + """Test creation of destroy step.""" + from sunbeam.storage.steps import BaseStorageBackendDestroyStep + + mock_client = Mock() + mock_tfhelper = Mock() + mock_manifest = Mock() + backend_name = "test-backend" + model = "openstack" + + step = backend.create_destroy_step( + mock_deployment, + mock_client, + mock_tfhelper, + mock_jhelper, + mock_manifest, + backend_name, + model, + ) + + assert isinstance(step, BaseStorageBackendDestroyStep) + + def test_register_add_cli(self, backend): + """Test CLI registration.""" + mock_add_group = Mock(spec=click.Group) + + with patch.object(backend, "_get_cli_class") as mock_get_cli_class: + mock_cli_class = Mock() + mock_cli_instance = Mock() + mock_cli_class.return_value = mock_cli_instance + mock_get_cli_class.return_value = mock_cli_class + + backend.register_add_cli(mock_add_group) + + mock_cli_class.assert_called_once_with(backend) + mock_cli_instance.register_add_cli.assert_called_once_with(mock_add_group) + + def test_get_cli_class_default(self, backend): + """Test getting CLI class with default implementation.""" + from sunbeam.storage.cli_base import StorageBackendCLIBase + + # For mock backend, it should return the base CLI class + cli_class = backend._get_cli_class() + assert cli_class == StorageBackendCLIBase + + def test_manifest_property(self, backend, mock_click_context): + """Test manifest property.""" + with patch("click.get_current_context", return_value=mock_click_context): + manifest = backend.manifest + assert manifest is not None + + def test_manifest_property_caching(self, backend, mock_click_context): + """Test that manifest is cached after first access.""" + with patch("click.get_current_context", return_value=mock_click_context): + manifest1 = backend.manifest + manifest2 = backend.manifest + # Should be the same object (cached) + assert manifest1 is manifest2 + + def test_manifest_property_failure(self, backend, mock_click_context): + """Test manifest property when loading fails.""" + mock_click_context.obj.get_manifest.return_value = None + + with patch("click.get_current_context", return_value=mock_click_context): + with pytest.raises(ValueError, match="Failed to load manifest"): + _ = backend.manifest + + def test_additional_integrations_default(self, backend): + """Test that additional_integrations returns a list.""" + integrations = backend.additional_integrations + assert isinstance(integrations, list) diff --git a/sunbeam-python/tests/unit/sunbeam/storage/test_hitachi.py b/sunbeam-python/tests/unit/sunbeam/storage/test_hitachi.py deleted file mode 100644 index d200a2e82..000000000 --- a/sunbeam-python/tests/unit/sunbeam/storage/test_hitachi.py +++ /dev/null @@ -1,575 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -"""Unit tests for Hitachi storage backend implementation.""" - -from unittest.mock import patch - -import click -import pytest -from pydantic import ValidationError - -from sunbeam.storage.backends.hitachi.backend import ( - HitachiBackend, - HitachiConfig, - HitachiDeployStep, - HitachiDestroyStep, - HitachiUpdateConfigStep, -) -from sunbeam.storage.models import StorageBackendConfig - - -class TestHitachiConfig: - """Test cases for HitachiConfig model.""" - - def test_valid_config_minimal(self): - """Test creating valid minimal Hitachi configuration.""" - config = HitachiConfig( - name="hitachi-backend-1", - hitachi_storage_id="123456", - hitachi_pools="pool1,pool2", - san_ip="192.168.1.100", - protocol="FC", - san_username="testuser", - san_password="testpassword", - ) - - assert config.name == "hitachi-backend-1" - assert config.hitachi_storage_id == "123456" - assert config.hitachi_pools == "pool1,pool2" - assert config.san_ip == "192.168.1.100" - assert config.protocol == "FC" # Default value - assert config.san_username == "testuser" - assert config.san_password == "testpassword" - - def test_valid_config_full(self): - """Test creating valid full Hitachi configuration.""" - config = HitachiConfig( - name="hitachi-backend-1", - hitachi_storage_id="123456", - hitachi_pools="pool1,pool2", - san_ip="192.168.1.100", - protocol="iSCSI", - san_username="testuser", - san_password="testpassword", - ) - - assert config.name == "hitachi-backend-1" - assert config.hitachi_storage_id == "123456" - assert config.hitachi_pools == "pool1,pool2" - assert config.san_ip == "192.168.1.100" - assert config.protocol == "iSCSI" - assert config.san_username == "testuser" - assert config.san_password == "testpassword" - - def test_config_with_iscsi_protocol(self): - """Test configuration with iSCSI protocol.""" - config = HitachiConfig( - name="hitachi-iscsi", - hitachi_storage_id="123456", - hitachi_pools="pool1", - san_ip="192.168.1.100", - protocol="iSCSI", - san_username="testuser", - san_password="testpassword", - ) - - assert config.protocol == "iSCSI" - assert config.san_username == "testuser" - assert config.san_password == "testpassword" - - def test_config_validation_missing_required_fields(self): - """Test validation errors for missing required fields.""" - # Missing hitachi_storage_id - with pytest.raises(ValidationError): - HitachiConfig(name="test", hitachi_pools="pool1", san_ip="192.168.1.100") - - # Missing hitachi_pools - with pytest.raises(ValidationError): - HitachiConfig( - name="test", hitachi_storage_id="123456", san_ip="192.168.1.100" - ) - - # Missing san_ip - with pytest.raises(ValidationError): - HitachiConfig( - name="test", hitachi_storage_id="123456", hitachi_pools="pool1" - ) - - def test_ip_validation_valid_ip(self): - """Test valid IP address validation.""" - config = HitachiConfig( - name="test", - hitachi_storage_id="123456", - hitachi_pools="pool1", - san_ip="192.168.1.100", - protocol="FC", - san_username="testuser", - san_password="testpassword", - ) - assert config.san_ip == "192.168.1.100" - - def test_ip_validation_valid_fqdn(self): - """Test valid FQDN validation.""" - config = HitachiConfig( - name="test", - hitachi_storage_id="123456", - hitachi_pools="pool1", - san_ip="storage.example.com", - protocol="FC", - san_username="testuser", - san_password="testpassword", - ) - assert config.san_ip == "storage.example.com" - - def test_protocol_validation(self): - """Test protocol field validation.""" - # Valid protocols - for protocol in ["FC", "iSCSI"]: - config = HitachiConfig( - name="test", - hitachi_storage_id="123456", - hitachi_pools="pool1", - san_ip="192.168.1.100", - protocol=protocol, - san_username="testuser", - san_password="testpassword", - ) - assert config.protocol == protocol - - def test_config_serialization(self): - """Test configuration serialization.""" - config = HitachiConfig( - name="test", - hitachi_storage_id="123456", - hitachi_pools="pool1", - san_ip="192.168.1.100", - protocol="FC", - san_username="testuser", - san_password="testpassword", - ) - - data = config.model_dump() - assert data["name"] == "test" - assert data["hitachi_storage_id"] == "123456" - assert data["hitachi_pools"] == "pool1" - assert data["san_ip"] == "192.168.1.100" - assert data["protocol"] == "FC" - assert data["san_username"] == "testuser" - assert data["san_password"] == "testpassword" - - def test_config_inheritance(self): - """Test that HitachiConfig inherits from StorageBackendConfig.""" - config = HitachiConfig( - name="test", - hitachi_storage_id="123456", - hitachi_pools="pool1", - san_ip="192.168.1.100", - protocol="FC", - san_username="testuser", - san_password="testpassword", - ) - - assert isinstance(config, StorageBackendConfig) - - -class TestHitachiBackend: - """Test cases for HitachiBackend class.""" - - def test_init(self): - """Test backend initialization.""" - backend = HitachiBackend() - - assert backend.name == "hitachi" - assert backend.display_name == "Hitachi VSP Storage" - assert backend.charm_name == "cinder-volume-hitachi" - assert backend.tfplan == "hitachi-backend-plan" - assert backend.tfplan_dir == "deploy-hitachi-backend" - assert ( - backend.charm_channel == "latest/edge" - ) # this have to be updated after the charm progress to stable - assert backend.charm_revision == 2 - assert backend.charm_base == "ubuntu@24.04" - assert backend.backend_endpoint == "cinder-volume" - assert backend.units == 1 - assert backend.additional_integrations == [] - - def test_config_class(self): - """Test configuration class retrieval.""" - backend = HitachiBackend() - config_class = backend.config_class - assert config_class == HitachiConfig - - def test_get_field_mapping(self): - """Test field mapping for charm configuration.""" - backend = HitachiBackend() - mapping = backend.get_field_mapping() - - # Test some key mappings - assert mapping["hitachi_storage_id"] == "hitachi-storage-id" - assert mapping["hitachi_pools"] == "hitachi-pools" - assert mapping["san_ip"] == "san-ip" - assert mapping["protocol"] == "protocol" - assert mapping["use_chap_auth"] == "use-chap-auth" - - def test_get_terraform_variables(self): - """Test Terraform variables generation.""" - backend = HitachiBackend() - config = HitachiConfig( - name="hitachi-backend-1", - hitachi_storage_id="123456", - hitachi_pools="pool1,pool2", - san_ip="192.168.1.100", - protocol="FC", - san_username="testuser", - san_password="testpassword", - ) - - variables = backend.get_terraform_variables( - "hitachi-backend-1", config, "openstack" - ) - - assert "machine_model" in variables - assert "hitachi_backends" in variables - assert variables["machine_model"] == "openstack" - assert "hitachi-backend-1" in variables["hitachi_backends"] - - backend_config = variables["hitachi_backends"]["hitachi-backend-1"] - assert "charm_config" in backend_config - - # Verify charm config contains the expected fields (excluding credentials) - charm_config = backend_config["charm_config"] - assert charm_config["hitachi-storage-id"] == "123456" - assert charm_config["hitachi-pools"] == "pool1,pool2" - assert charm_config["san-ip"] == "192.168.1.100" - # Protocol field is excluded when it matches default value - assert "protocol" not in charm_config - # Credentials should not be in charm config - they go in secrets - assert "san-username" not in charm_config - assert "san-password" not in charm_config - - def test_should_include_config_value(self): - """Test configuration value inclusion logic.""" - backend = HitachiBackend() - - # Non-default string value should be included - assert backend._should_include_config_value("san-ip", "192.168.1.100", "") - - # Default string value should not be included - assert not backend._should_include_config_value( - "san-login", "maintenance", "maintenance" - ) - - # Empty string should not be included - assert not backend._should_include_config_value("san-ip", "", "") - - # Non-empty list should be included - assert backend._should_include_config_value("hitachi-pools", ["pool1"], []) - - # Empty list should not be included - assert not backend._should_include_config_value("hitachi-pools", [], []) - - # None values should not be included - assert not backend._should_include_config_value("optional-field", None, None) - - def test_create_deploy_step( - self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest - ): - """Test deployment step creation.""" - backend = HitachiBackend() - config = HitachiConfig( - name="test", - hitachi_storage_id="123456", - hitachi_pools="pool1", - san_ip="192.168.1.100", - protocol="FC", - san_username="testuser", - san_password="testpassword", - ) - - step = backend.create_deploy_step( - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - "test-backend", - config, - "openstack", - ) - - assert isinstance(step, HitachiDeployStep) - assert step.backend_name == "test-backend" - assert step.backend_config == config - assert step.model == "openstack" - - def test_create_destroy_step( - self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest - ): - """Test destruction step creation.""" - backend = HitachiBackend() - - step = backend.create_destroy_step( - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - "test-backend", - "openstack", - ) - - assert isinstance(step, HitachiDestroyStep) - assert step.backend_name == "test-backend" - assert step.model == "openstack" - - def test_create_update_config_step(self, mock_deployment): - """Test configuration update step creation.""" - backend = HitachiBackend() - config_updates = {"key1": "value1", "key2": "value2"} - - step = backend.create_update_config_step( - mock_deployment, "test-backend", config_updates - ) - - assert isinstance(step, HitachiUpdateConfigStep) - assert step.backend_name == "test-backend" - assert step.config_updates == config_updates - - @patch("click.confirm") - @patch("click.prompt") - def test_prompt_for_config(self, mock_prompt, mock_confirm, mock_deployment): - """Test configuration prompting.""" - backend = HitachiBackend() - - # Mock user inputs for all prompts in _prompt_for_config - mock_prompt.side_effect = [ - "123456", # hitachi_storage_id (Array serial number) - "pool1,pool2", # hitachi_pools (Storage pools) - "FC", # protocol - "192.168.1.100", # san_ip (Management IP/FQDN) - "maintenance", # san_username (SAN username) - "secret123", # san_password (SAN password) - "test-backend", # volume_backend_name - ] - - # Mock confirmation prompts (all False to avoid optional config) - mock_confirm.side_effect = [ - False, # configure_mirror (Configure mirror/replication) - ] - - config = backend.prompt_for_config("test-backend") - - assert isinstance(config, HitachiConfig) - assert config.name == "test-backend" - assert config.hitachi_storage_id == "123456" - assert config.hitachi_pools == "pool1,pool2" - assert config.san_ip == "192.168.1.100" - # Protocol should use default value - assert config.protocol == "FC" - - def test_validate_ip_or_fqdn_valid_ip(self): - """Test IP validation with valid IP.""" - # Should not raise exception - HitachiBackend._validate_ip_or_fqdn("192.168.1.100") - - def test_validate_ip_or_fqdn_valid_fqdn(self): - """Test IP validation with valid FQDN.""" - # Should not raise exception - HitachiBackend._validate_ip_or_fqdn("storage.example.com") - - def test_validate_ip_or_fqdn_invalid(self): - """Test IP validation with invalid input.""" - with pytest.raises(click.BadParameter): - HitachiBackend._validate_ip_or_fqdn("..invalid..") - - -class TestHitachiSteps: - """Test cases for Hitachi-specific step implementations.""" - - def test_hitachi_deploy_step_init( - self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest - ): - """Test HitachiDeployStep initialization.""" - backend_instance = HitachiBackend() - backend_name = "hitachi-backend-1" - model = "openstack" - - config = HitachiConfig( - name=backend_name, - hitachi_storage_id="123456", - hitachi_pools="pool1,pool2", - san_ip="192.168.1.100", - protocol="FC", - san_username="testuser", - san_password="testpassword", - ) - - step = HitachiDeployStep( - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - backend_name, - config, - backend_instance, - model, - ) - - assert step.backend_name == backend_name - assert step.backend_config == config - assert step.backend_instance == backend_instance - assert step.model == model - - def test_hitachi_deploy_step_get_terraform_variables( - self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest - ): - """Test Terraform variables generation in deploy step.""" - backend_instance = HitachiBackend() - backend_name = "hitachi-backend-1" - model = "openstack" - - config = HitachiConfig( - name=backend_name, - hitachi_storage_id="123456", - hitachi_pools="pool1,pool2", - san_ip="192.168.1.100", - protocol="FC", - san_username="testuser", - san_password="testpassword", - ) - - step = HitachiDeployStep( - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - backend_name, - config, - backend_instance, - model, - ) - - variables = step.get_terraform_variables() - - assert "machine_model" in variables - assert "hitachi_backends" in variables - assert variables["machine_model"] == "openstack" - assert "hitachi-backend-1" in variables["hitachi_backends"] - - backend_config = variables["hitachi_backends"]["hitachi-backend-1"] - assert "charm_config" in backend_config - - def test_hitachi_destroy_step_init( - self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest - ): - """Test HitachiDestroyStep initialization.""" - backend_instance = HitachiBackend() - backend_name = "hitachi-backend-1" - model = "openstack" - - step = HitachiDestroyStep( - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - backend_name, - backend_instance, - model, - ) - - assert step.backend_name == backend_name - assert step.backend_instance == backend_instance - assert step.model == model - - def test_hitachi_update_config_step_init(self, mock_deployment): - """Test HitachiUpdateConfigStep initialization.""" - backend_instance = HitachiBackend() - backend_name = "hitachi-backend-1" - config_updates = {"san-ip": "192.168.1.101"} - - step = HitachiUpdateConfigStep( - mock_deployment, backend_instance, backend_name, config_updates - ) - - assert step.backend_name == backend_name - assert step.backend_instance == backend_instance - assert step.config_updates == config_updates - - def test_hitachi_deploy_step_creation( - self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest - ): - """Test Hitachi deploy step creation.""" - backend_instance = HitachiBackend() - backend_name = "hitachi-backend-1" - model = "openstack" - - config = HitachiConfig( - name=backend_name, - hitachi_storage_id="123456", - hitachi_pools="pool1", - san_ip="192.168.1.100", - protocol="FC", - san_username="testuser", - san_password="testpassword", - ) - - step = HitachiDeployStep( - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - backend_name, - config, - backend_instance, - model, - ) - - # Test that step was created successfully - assert step is not None - assert step.backend_name == backend_name - assert step.backend_instance == backend_instance - - def test_hitachi_destroy_step_creation( - self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest - ): - """Test Hitachi destroy step creation.""" - backend_instance = HitachiBackend() - backend_name = "hitachi-backend-1" - model = "openstack" - - step = HitachiDestroyStep( - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - backend_name, - backend_instance, - model, - ) - - # Test that step was created successfully - assert step is not None - assert step.backend_name == backend_name - assert step.backend_instance == backend_instance - - def test_hitachi_update_config_step_creation(self, mock_deployment): - """Test Hitachi update config step creation.""" - backend_instance = HitachiBackend() - backend_name = "hitachi-backend-1" - config_updates = {"san-ip": "192.168.1.101"} - - step = HitachiUpdateConfigStep( - mock_deployment, backend_instance, backend_name, config_updates - ) - - # Test that step was created successfully - assert step is not None - assert step.backend_name == backend_name - assert step.backend_instance == backend_instance - assert step.config_updates == config_updates diff --git a/sunbeam-python/tests/unit/sunbeam/storage/test_manager.py b/sunbeam-python/tests/unit/sunbeam/storage/test_manager.py new file mode 100644 index 000000000..3888f651c --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/test_manager.py @@ -0,0 +1,377 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for StorageBackendManager class.""" + +from unittest.mock import Mock, patch + +import click +import pytest + +from sunbeam.storage.manager import StorageBackendManager +from sunbeam.storage.models import StorageBackendInfo + + +@pytest.fixture +def manager(): + """Create a fresh manager instance.""" + # Reset the class-level state before each test + StorageBackendManager._backends = {} + StorageBackendManager._loaded = False + return StorageBackendManager() + + +@pytest.fixture +def mock_backend(): + """Create a mock backend.""" + from sunbeam.core.common import RiskLevel + + backend = Mock() + backend.backend_type = "test-backend" + backend.display_name = "Test Backend" + backend.register_add_cli = Mock() + backend.register_options_cli = Mock() + backend.risk_availability = RiskLevel.STABLE + return backend + + +@pytest.fixture +def mock_deployment(): + """Create a mock deployment.""" + deployment = Mock() + deployment.openstack_machines_model = "openstack" + deployment.juju_controller = "test-controller" + return deployment + + +class TestStorageBackendManager: + """Tests for StorageBackendManager.""" + + def test_init_loads_backends(self, manager): + """Test that initialization triggers backend loading.""" + with patch.object(manager, "_load_backends") as mock_load: + # Create a new manager + new_manager = StorageBackendManager() + # _load_backends should be called if backends are empty + if not new_manager._backends: + mock_load.assert_called_once() + + def test_load_backends_sets_loaded_flag(self, manager): + """Test that _load_backends sets the loaded flag.""" + # Reset the loaded flag + manager._loaded = False + assert not manager._loaded + with patch("importlib.import_module"): + with patch("pathlib.Path.iterdir", return_value=[]): + manager._load_backends() + assert manager._loaded + + def test_load_backends_only_once(self, manager): + """Test that backends are only loaded once.""" + with patch("importlib.import_module"): + with patch("pathlib.Path.iterdir", return_value=[]): + manager._load_backends() + manager._load_backends() + # Should only load once due to _loaded flag + # But init also calls it, so check the total is reasonable + + def test_load_backends_skips_non_directories(self, manager): + """Test that non-directory items are skipped.""" + mock_file = Mock() + mock_file.is_dir.return_value = False + mock_file.name = "test.py" + + with patch("pathlib.Path.iterdir", return_value=[mock_file]): + with patch("importlib.import_module"): + manager._load_backends() + # Should not attempt to import non-directories + + def test_load_backends_skips_special_directories(self, manager): + """Test that special directories are skipped.""" + special_dirs = ["__pycache__", "_internal", "etc"] + mock_paths = [] + + for name in special_dirs: + mock_path = Mock() + mock_path.is_dir.return_value = True + mock_path.name = name + mock_paths.append(mock_path) + + with patch("pathlib.Path.iterdir", return_value=mock_paths): + with patch("importlib.import_module") as mock_import: + manager._load_backends() + # Should not attempt to import special directories + mock_import.assert_not_called() + + def test_load_backends_skips_missing_backend_file(self, manager): + """Test that directories without backend.py are skipped.""" + mock_dir = Mock() + mock_dir.is_dir.return_value = True + mock_dir.name = "test-backend" + mock_dir.__truediv__ = Mock(return_value=Mock(exists=Mock(return_value=False))) + + with patch("pathlib.Path.iterdir", return_value=[mock_dir]): + with patch("importlib.import_module") as mock_import: + manager._load_backends() + # Should not attempt to import if backend.py is missing + mock_import.assert_not_called() + + def test_load_backends_registers_valid_backend(self, manager): + """Test that valid backends are registered.""" + from sunbeam.storage.base import StorageBackendBase + + # Create a mock backend class + class TestBackend(StorageBackendBase): + backend_type = "test" + display_name = "Test Backend" + + @property + def charm_name(self): + return "test-charm" + + def config_type(self): + from sunbeam.core.manifest import StorageBackendConfig + + return StorageBackendConfig + + # Create mock module with backend class + mock_module = Mock() + mock_module.TestBackend = TestBackend + + mock_dir = Mock() + mock_dir.is_dir.return_value = True + mock_dir.name = "test" + backend_py = Mock() + backend_py.exists.return_value = True + mock_dir.__truediv__ = Mock(return_value=backend_py) + + with patch("pathlib.Path.iterdir", return_value=[mock_dir]): + with patch("importlib.import_module", return_value=mock_module): + with patch("pathlib.Path.exists", return_value=True): + # Clear existing backends and reset loaded flag + manager._backends = {} + manager._loaded = False + manager._load_backends() + # Backend should be registered + assert "test" in manager._backends + + def test_load_backends_handles_import_error(self, manager): + """Test that import errors are handled gracefully.""" + mock_dir = Mock() + mock_dir.is_dir.return_value = True + mock_dir.name = "broken-backend" + backend_py = Mock() + backend_py.exists.return_value = True + mock_dir.__truediv__ = Mock(return_value=backend_py) + + with patch("pathlib.Path.iterdir", return_value=[mock_dir]): + with patch( + "importlib.import_module", side_effect=ImportError("Test error") + ): + # Should not raise, just log warning + manager._load_backends() + + def test_get_backend_success(self, manager, mock_backend): + """Test getting a backend by name.""" + manager._backends["test-backend"] = mock_backend + manager._loaded = True + + backend = manager.get_backend("test-backend") + assert backend == mock_backend + + def test_get_backend_not_found(self, manager): + """Test getting a non-existent backend.""" + manager._loaded = True + + with pytest.raises(ValueError, match="Storage backend .* not found"): + manager.get_backend("nonexistent") + + def test_backends_property(self, manager, mock_backend): + """Test backends property returns all backends.""" + manager._backends["test-backend"] = mock_backend + manager._loaded = True + + backends = manager.backends() + assert "test-backend" in backends + assert backends["test-backend"] == mock_backend + + def test_get_all_storage_manifests(self, manager, mock_backend): + """Test getting all storage manifests.""" + manager._backends["test-backend"] = mock_backend + manager._loaded = True + + manifests = manager.get_all_storage_manifests() + assert isinstance(manifests, dict) + assert "test-backend" in manifests + assert manifests["test-backend"] == {} + + def test_register(self, manager, mock_deployment): + """Test registering storage commands.""" + mock_cli_group = Mock(spec=click.Group) + + with patch.object(manager, "register_cli_commands"): + manager.register(mock_cli_group, mock_deployment) + mock_cli_group.add_command.assert_called_once() + + def test_register_handles_errors(self, manager, mock_deployment): + """Test that register handles errors appropriately.""" + mock_cli_group = Mock(spec=click.Group) + + with patch.object( + manager, "register_cli_commands", side_effect=ValueError("Test error") + ): + with pytest.raises(ValueError): + manager.register(mock_cli_group, mock_deployment) + + def test_register_cli_commands(self, manager, mock_backend, mock_deployment): + """Test CLI command registration.""" + from sunbeam.core.common import RiskLevel + + manager._backends["test-backend"] = mock_backend + manager._loaded = True + + mock_storage_group = Mock(spec=click.Group) + + # Mock infer_risk to return a default risk level + with patch("sunbeam.storage.manager.infer_risk", return_value=RiskLevel.STABLE): + manager.register_cli_commands(mock_storage_group, mock_deployment) + + # Verify that backend's register_add_cli was called + mock_backend.register_add_cli.assert_called_once() + + # Verify that commands were added to the group + assert mock_storage_group.add_command.called + + def test_register_cli_commands_handles_backend_errors( + self, manager, mock_backend, mock_deployment + ): + """Test that CLI registration handles backend errors.""" + from sunbeam.core.common import RiskLevel + + manager._backends["test-backend"] = mock_backend + manager._loaded = True + mock_backend.register_add_cli.side_effect = ValueError("Backend error") + + mock_storage_group = Mock(spec=click.Group) + + # Mock infer_risk to return a default risk level + with patch("sunbeam.storage.manager.infer_risk", return_value=RiskLevel.STABLE): + with pytest.raises(ValueError): + manager.register_cli_commands(mock_storage_group, mock_deployment) + + def test_display_backends_table_empty(self, manager): + """Test displaying empty backend list.""" + from rich.console import Console + + mock_console = Mock(spec=Console) + + with patch("sunbeam.storage.manager.console", mock_console): + manager._display_backends_table([]) + # Should print a message about no backends + mock_console.print.assert_called_once() + + def test_display_backends_table_with_backends(self, manager): + """Test displaying backend list.""" + from rich.console import Console + + backends = [ + StorageBackendInfo( + name="backend1", + backend_type="type1", + status="active", + charm="charm1", + config={}, + ), + StorageBackendInfo( + name="backend2", + backend_type="type2", + status="error", + charm="charm2", + config={}, + ), + ] + + mock_console = Mock(spec=Console) + + with patch("sunbeam.storage.manager.console", mock_console): + manager._display_backends_table(backends) + # Should print the table + assert mock_console.print.called + + +class TestStorageCLICommands: + """Test the CLI command functions created by the manager.""" + + def test_list_all_command(self, manager, mock_deployment): + """Test the list command.""" + from sunbeam.core.common import RiskLevel + from sunbeam.storage.service import StorageBackendService + + mock_service = Mock(spec=StorageBackendService) + mock_service.list_backends.return_value = [] + + with patch( + "sunbeam.storage.manager.StorageBackendService", return_value=mock_service + ): + with patch.object(manager, "_display_backends_table"): + with patch( + "sunbeam.storage.manager.infer_risk", return_value=RiskLevel.STABLE + ): + manager._loaded = True + mock_storage_group = Mock(spec=click.Group) + manager.register_cli_commands(mock_storage_group, mock_deployment) + + # Get the list command that was registered + calls = mock_storage_group.add_command.call_args_list + list_command = None + for call in calls: + if call[0][0].name == "list": + list_command = call[0][0] + break + + assert list_command is not None + + def test_remove_backend_command_success(self, manager, mock_deployment): + """Test the remove command with successful removal.""" + from sunbeam.core.common import RiskLevel + + manager._backends["test-backend"] = Mock() + manager._loaded = True + + mock_storage_group = Mock(spec=click.Group) + + # Mock infer_risk to return a default risk level + with patch("sunbeam.storage.manager.infer_risk", return_value=RiskLevel.STABLE): + manager.register_cli_commands(mock_storage_group, mock_deployment) + + # Verify remove command was registered + calls = mock_storage_group.add_command.call_args_list + remove_command = None + for call in calls: + if hasattr(call[0][0], "name") and call[0][0].name == "remove": + remove_command = call[0][0] + break + + assert remove_command is not None + + def test_show_backend_command(self, manager, mock_deployment): + """Test the show command.""" + from sunbeam.core.common import RiskLevel + + manager._loaded = True + + mock_storage_group = Mock(spec=click.Group) + + # Mock infer_risk to return a default risk level + with patch("sunbeam.storage.manager.infer_risk", return_value=RiskLevel.STABLE): + manager.register_cli_commands(mock_storage_group, mock_deployment) + + # Verify show command was registered + calls = mock_storage_group.add_command.call_args_list + show_command = None + for call in calls: + if hasattr(call[0][0], "name") and call[0][0].name == "show": + show_command = call[0][0] + break + + assert show_command is not None diff --git a/sunbeam-python/tests/unit/sunbeam/storage/test_models.py b/sunbeam-python/tests/unit/sunbeam/storage/test_models.py deleted file mode 100644 index e56fea157..000000000 --- a/sunbeam-python/tests/unit/sunbeam/storage/test_models.py +++ /dev/null @@ -1,170 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -"""Unit tests for storage backend models.""" - -import pytest -from pydantic import ValidationError - -from sunbeam.storage.models import ( - BackendAlreadyExistsException, - BackendNotFoundException, - BackendValidationException, - StorageBackendConfig, - StorageBackendException, - StorageBackendInfo, -) - - -class TestStorageBackendConfig: - """Test cases for StorageBackendConfig model.""" - - def test_valid_config(self): - """Test creating valid configuration.""" - config = StorageBackendConfig(name="test-backend") - assert config.name == "test-backend" - - def test_invalid_config_missing_name(self): - """Test validation error for missing name.""" - with pytest.raises(ValidationError): - StorageBackendConfig() - - def test_config_serialization(self): - """Test configuration serialization.""" - config = StorageBackendConfig(name="test-backend") - data = config.model_dump() - assert data == {"name": "test-backend"} - - def test_config_from_dict(self): - """Test creating configuration from dictionary.""" - data = {"name": "test-backend"} - config = StorageBackendConfig(**data) - assert config.name == "test-backend" - - -class TestStorageBackendInfo: - """Test cases for StorageBackendInfo model.""" - - def test_valid_info(self): - """Test creating valid backend info.""" - info = StorageBackendInfo( - name="test-backend", - backend_type="hitachi", - status="active", - charm="cinder-volume-hitachi", - config={"key": "value"}, - ) - assert info.name == "test-backend" - assert info.backend_type == "hitachi" - assert info.status == "active" - assert info.charm == "cinder-volume-hitachi" - assert info.config == {"key": "value"} - - def test_info_with_defaults(self): - """Test creating info with default values.""" - info = StorageBackendInfo( - name="test-backend", - backend_type="hitachi", - status="active", - charm="cinder-volume-hitachi", - ) - assert info.config == {} - - def test_info_serialization(self): - """Test backend info serialization.""" - info = StorageBackendInfo( - name="test-backend", - backend_type="hitachi", - status="active", - charm="cinder-volume-hitachi", - config={"key": "value"}, - ) - data = info.model_dump() - expected = { - "name": "test-backend", - "backend_type": "hitachi", - "status": "active", - "charm": "cinder-volume-hitachi", - "config": {"key": "value"}, - } - assert data == expected - - def test_info_from_dict(self): - """Test creating backend info from dictionary.""" - data = { - "name": "test-backend", - "backend_type": "hitachi", - "status": "active", - "charm": "cinder-volume-hitachi", - "config": {"key": "value"}, - } - info = StorageBackendInfo(**data) - assert info.name == "test-backend" - assert info.backend_type == "hitachi" - assert info.status == "active" - assert info.charm == "cinder-volume-hitachi" - assert info.config == {"key": "value"} - - -class TestStorageBackendExceptions: - """Test cases for storage backend exceptions.""" - - def test_storage_backend_exception(self): - """Test base storage backend exception.""" - exc = StorageBackendException("Test error") - assert str(exc) == "Test error" - assert isinstance(exc, Exception) - - def test_backend_not_found_exception(self): - """Test backend not found exception.""" - exc = BackendNotFoundException("Backend not found") - assert str(exc) == "Backend not found" - assert isinstance(exc, StorageBackendException) - - def test_backend_already_exists_exception(self): - """Test backend already exists exception.""" - exc = BackendAlreadyExistsException("Backend already exists") - assert str(exc) == "Backend already exists" - assert isinstance(exc, StorageBackendException) - - def test_backend_validation_exception(self): - """Test backend validation exception.""" - exc = BackendValidationException("Validation failed") - assert str(exc) == "Validation failed" - assert isinstance(exc, StorageBackendException) - - def test_exception_inheritance(self): - """Test exception inheritance hierarchy.""" - # All custom exceptions should inherit from StorageBackendException - exceptions = [ - BackendNotFoundException("test"), - BackendAlreadyExistsException("test"), - BackendValidationException("test"), - ] - - for exc in exceptions: - assert isinstance(exc, StorageBackendException) - assert isinstance(exc, Exception) - - def test_exception_with_no_message(self): - """Test exceptions with no message.""" - exc = StorageBackendException() - assert isinstance(exc, Exception) - - exc = BackendNotFoundException() - assert isinstance(exc, StorageBackendException) - - def test_exception_chaining(self): - """Test exception chaining.""" - original = ValueError("Original error") - chained = None - try: - raise original - except ValueError as e: - try: - raise StorageBackendException("Chained error") from e - except StorageBackendException as chained_exc: - chained = chained_exc - - assert str(chained) == "Chained error" - assert chained.__cause__ is original diff --git a/sunbeam-python/tests/unit/sunbeam/storage/test_registry.py b/sunbeam-python/tests/unit/sunbeam/storage/test_registry.py deleted file mode 100644 index 9db18185f..000000000 --- a/sunbeam-python/tests/unit/sunbeam/storage/test_registry.py +++ /dev/null @@ -1,504 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -"""Unit tests for storage backend registry.""" - -from unittest.mock import Mock, patch - -import pytest - -from sunbeam.storage.base import StorageBackendBase -from sunbeam.storage.models import StorageBackendConfig -from sunbeam.storage.registry import StorageBackendRegistry - - -class MockStorageBackend(StorageBackendBase): - """Mock storage backend for testing.""" - - name = "mock" - display_name = "Mock Storage" - description = "Mock storage backend for testing" - terraform_plan_location = "/path/to/mock/terraform" - - def __init__(self, deployment=None): - super().__init__() - self.deployment = deployment - - def _get_default_config(self) -> dict: - """Get default configuration for mock backend.""" - return {"mock_key": "default_value"} - - def prompt_for_config(self) -> dict: - """Mock prompt for config.""" - return {"mock_key": "mock_value"} - - @property - def tfvar_config_key(self): - """Config key for storing Terraform variables in clusterd.""" - return f"TerraformVars{self.name.title()}Backend" - - @property - def config_class(self): - return StorageBackendConfig - - def create_deploy_step( - self, - deployment, - client, - tfhelper, - jhelper, - manifest, - backend_name, - backend_config, - model, - ): - """Create a mock deployment step.""" - # Use test-specific mock step if available; otherwise base class - try: - from sunbeam.storage.steps import MockDeployStep # type: ignore - - return MockDeployStep( - deployment, - client, - tfhelper, - jhelper, - manifest, - backend_name, - backend_config, - self, - model, - ) - except Exception: - from sunbeam.storage.steps import BaseStorageBackendDeployStep - - return BaseStorageBackendDeployStep( - deployment, - client, - tfhelper, - jhelper, - manifest, - backend_name, - backend_config, - self, - model, - ) - - def create_destroy_step( - self, deployment, client, tfhelper, jhelper, manifest, backend_name, model - ): - """Create a mock destruction step.""" - # Use test-specific mock step if available; otherwise base class - try: - from sunbeam.storage.steps import MockDestroyStep # type: ignore - - return MockDestroyStep( - deployment, - client, - tfhelper, - jhelper, - manifest, - backend_name, - self, - model, - ) - except Exception: - from sunbeam.storage.steps import BaseStorageBackendDestroyStep - - return BaseStorageBackendDestroyStep( - deployment, - client, - tfhelper, - jhelper, - manifest, - backend_name, - self, - model, - ) - - def create_update_config_step(self, deployment, backend_name, config_updates): - """Create a mock configuration update step.""" - from sunbeam.storage.steps import BaseStorageBackendConfigUpdateStep - - return BaseStorageBackendConfigUpdateStep( - deployment, self, backend_name, config_updates - ) - - def prompt_for_config(self, backend_name: str) -> StorageBackendConfig: # noqa: F811 - """Mock prompt for configuration.""" - return StorageBackendConfig(name=backend_name) - - def register_add_cli(self, add): - """Mock CLI registration.""" - pass - - def register_cli( - self, remove, config_show, config_set, config_reset, config_options, deployment - ): - """Mock CLI registration.""" - pass - - def get_terraform_variables( - self, backend_name: str, config: StorageBackendConfig, model: str - ): - return { - "model": model, - "mock_backends": { - backend_name: { - "backend_type": self.name, - "charm_name": self.charm_name, - "charm_channel": "latest/stable", - "backend_config": config.model_dump(), - "backend_endpoint": "storage-backend", - "units": 1, - "additional_integrations": {}, - } - }, - } - - def commands(self): - """Return mock commands for testing.""" - return { - "add": [{"name": "mock", "command": Mock()}], - "remove": [{"name": "mock", "command": Mock()}], - "list": [{"name": "mock", "command": Mock()}], - "config": [{"name": "mock", "command": Mock()}], - } - - -class TestStorageBackendRegistry: - """Test cases for StorageBackendRegistry class.""" - - def test_init(self): - """Test registry initialization.""" - registry = StorageBackendRegistry() - - assert registry._backends == {} - assert hasattr(registry, "_loaded") - assert registry._loaded is False - - def test_load_backends_success(self): - """Test successful backend loading by directly adding a backend.""" - registry = StorageBackendRegistry() - - # Directly add a mock backend to test registry functionality - mock_backend = MockStorageBackend() - registry._backends["mock"] = mock_backend - registry._loaded = True - - assert registry._loaded is True - assert "mock" in registry._backends - assert isinstance(registry._backends["mock"], MockStorageBackend) - - @patch("sunbeam.storage.registry.pathlib.Path") - def test_load_backends_no_backends_dir(self, mock_path): - """Test loading when backends directory doesn't exist.""" - registry = StorageBackendRegistry() - - # Mock the pathlib.Path chain: - # pathlib.Path(sunbeam.storage.backends.__file__).parent - mock_backends_dir = Mock() - mock_backends_dir.iterdir.return_value = [] # Empty for no backends case - - mock_path.return_value.parent = mock_backends_dir - - registry._load_backends() - - assert registry._loaded is True - assert registry._backends == {} - - @patch("sunbeam.storage.registry.pathlib.Path") - def test_load_backends_empty_dir(self, mock_path): - """Test backend loading with empty backends directory.""" - registry = StorageBackendRegistry() - - # Mock the pathlib.Path chain: - # pathlib.Path(sunbeam.storage.backends.__file__).parent - mock_backends_dir = Mock() - mock_backends_dir.iterdir.return_value = [] - - mock_path.return_value.parent = mock_backends_dir - - registry._load_backends() - - assert registry._loaded is True - assert registry._backends == {} - - def test_get_backend_success(self): - """Test successful backend retrieval.""" - registry = StorageBackendRegistry() - mock_backend = MockStorageBackend() - registry._backends = {"mock": mock_backend} - registry._loaded = True - - backend = registry.get_backend("mock") - assert backend == mock_backend - - def test_get_backend_not_found(self): - """Test backend retrieval for non-existent backend.""" - registry = StorageBackendRegistry() - registry._backends = {} - registry._loaded = True - - with pytest.raises(ValueError, match="Storage backend 'nonexistent' not found"): - registry.get_backend("nonexistent") - - def test_get_backend_auto_load(self): - """Test that get_backend automatically loads backends if not loaded.""" - registry = StorageBackendRegistry() - - with patch.object(registry, "_load_backends") as mock_load: - mock_backend = MockStorageBackend() - registry._backends = {"mock": mock_backend} - registry._loaded = True - - backend = registry.get_backend("mock") - mock_load.assert_called_once() - assert backend == mock_backend - - def test_list_backends(self): - """Test backend listing.""" - registry = StorageBackendRegistry() - mock_backend1 = MockStorageBackend() - mock_backend2 = MockStorageBackend() - mock_backend2.name = "mock2" - - registry._backends = {"mock": mock_backend1, "mock2": mock_backend2} - registry._loaded = True - - backends = registry.list_backends() - assert len(backends) == 2 - assert "mock" in backends - assert "mock2" in backends - assert backends["mock"] == mock_backend1 - assert backends["mock2"] == mock_backend2 - - def test_list_backends_auto_load(self): - """Test that list_backends automatically loads backends if not loaded.""" - registry = StorageBackendRegistry() - - with patch.object(registry, "_load_backends") as mock_load: - registry._backends = {"mock": MockStorageBackend()} - registry._loaded = True - - backends = registry.list_backends() - mock_load.assert_called_once() - assert len(backends) == 1 - - def test_register_add_commands_via_backend(self, mock_deployment): - """Test that add commands are registered via backend.register_add_cli().""" - registry = StorageBackendRegistry() - mock_backend = MockStorageBackend() - registry._backends = {"mock": mock_backend} - registry._loaded = True - - mock_storage_group = Mock() - - with patch.object(mock_backend, "register_add_cli") as mock_register_add: - registry.register_cli_commands(mock_storage_group, mock_deployment) - - # Verify that the backend's register_add_cli method was called - mock_register_add.assert_called_once() - # Verify that groups were added to the storage group - assert mock_storage_group.add_command.call_count >= 4 - - def test_register_remove_commands_via_backend(self, mock_deployment): - """Test that remove commands are registered via backend.register_cli().""" - registry = StorageBackendRegistry() - mock_backend = MockStorageBackend() - registry._backends = {"mock": mock_backend} - registry._loaded = True - - mock_storage_group = Mock() - - with patch.object(mock_backend, "register_cli") as mock_register_cli: - registry.register_cli_commands(mock_storage_group, mock_deployment) - - # Verify that the backend's register_cli method was called - mock_register_cli.assert_called_once() - # Verify that groups were added to the storage group - assert mock_storage_group.add_command.call_count >= 4 - - def test_register_list_commands_includes_all(self, mock_deployment): - """Test that list commands include the 'all' command.""" - registry = StorageBackendRegistry() - mock_backend = MockStorageBackend() - registry._backends = {"mock": mock_backend} - registry._loaded = True - - mock_storage_group = Mock() - - with patch("sunbeam.storage.registry.StorageBackendService"): - registry.register_cli_commands(mock_storage_group, mock_deployment) - - # Verify that groups were added to the storage group - assert mock_storage_group.add_command.call_count >= 4 - - def test_register_config_commands_includes_subgroups(self, mock_deployment): - """Test that config commands include show, set, reset, options subgroups.""" - registry = StorageBackendRegistry() - mock_backend = MockStorageBackend() - registry._backends = {"mock": mock_backend} - registry._loaded = True - - mock_storage_group = Mock() - - with patch.object(mock_backend, "register_cli") as mock_register_cli: - registry.register_cli_commands(mock_storage_group, mock_deployment) - - # Verify that the backend's register_cli method was called with config - # subgroups - mock_register_cli.assert_called_once() - # The call should include the config subgroups as arguments - call_args = mock_register_cli.call_args[0] - assert ( - len(call_args) >= 5 - ) # remove_group, config_show, config_set, config_reset, config_options - - def test_register_commands_all_groups(self, mock_deployment): - """Test registration of all command groups.""" - registry = StorageBackendRegistry() - mock_backend = MockStorageBackend() - registry._backends = {"mock": mock_backend} - registry._loaded = True - - mock_cli = Mock() - - with ( - patch.object(mock_backend, "register_add_cli") as mock_add, - patch.object(mock_backend, "register_cli") as mock_register_cli, - ): - registry.register_cli_commands(mock_cli, mock_deployment) - - # Verify that backend CLI registration methods were called - mock_add.assert_called_once() - mock_register_cli.assert_called_once() - - # Verify that the CLI groups were added to the storage group - assert ( - mock_cli.add_command.call_count >= 4 - ) # add, remove, list, config groups - - def test_backend_discovery_error_handling(self): - """Test error handling during backend discovery.""" - registry = StorageBackendRegistry() - - with ( - patch("sunbeam.storage.registry.importlib.import_module"), - patch("sunbeam.storage.registry.pathlib.Path") as mock_path, - ): - mock_backends_dir = Mock() - mock_backends_dir.iterdir.side_effect = Exception("Directory read error") - - mock_path.return_value.parent = mock_backends_dir - - # Registry should handle directory iteration gracefully - registry._load_backends() - - # Should still mark as loaded even with directory errors - assert registry._loaded is True - assert registry._backends == {} - - def test_module_loading_error_handling(self): - """Test error handling during module loading.""" - registry = StorageBackendRegistry() - - with ( - patch("sunbeam.storage.registry.pathlib.Path") as mock_path, - patch( - "sunbeam.storage.registry.importlib.import_module" - ) as mock_import_module, - ): - mock_backends_dir = Mock() - mock_backends_dir.exists.return_value = True - mock_backends_dir.is_dir.return_value = True - - mock_backend_dir = Mock() - mock_backend_dir.name = "mock" - mock_backend_dir.is_dir.return_value = True - - # Set up path operations for mock_backend_dir - mock_backend_module_path = Mock() - mock_backend_module_path.exists.return_value = True - mock_backend_dir.__truediv__ = Mock(return_value=mock_backend_module_path) - - mock_backends_dir.iterdir.return_value = [mock_backend_dir] - - mock_path.return_value.parent = mock_backends_dir - - mock_import_module.side_effect = Exception("Module loading error") - - # Should not raise exception, just log error and continue - registry._load_backends() - - assert registry._loaded is True - assert registry._backends == {} - - def test_singleton_behavior(self): - """Test that registry instances are independent (not singleton).""" - registry1 = StorageBackendRegistry() - registry2 = StorageBackendRegistry() - - # Should be different instances - assert registry1 is not registry2 - assert registry1._backends is not registry2._backends - - def test_backend_validation(self): - """Test backend validation during loading.""" - registry = StorageBackendRegistry() - - # Mock a class that doesn't inherit from StorageBackendBase - class InvalidBackend: - name = "invalid" - - # Directly test validation by adding backends - # Valid backend should be accepted - valid_backend = MockStorageBackend() - registry._backends["mock"] = valid_backend - - # Test that we can access the valid backend - assert len(registry._backends) == 1 - assert "mock" in registry._backends - assert isinstance(registry._backends["mock"], MockStorageBackend) - - def test_command_registration_with_no_backends(self, mock_deployment): - """Test command registration when no backends are loaded.""" - registry = StorageBackendRegistry() - registry._backends = {} - registry._loaded = True - - mock_cli = Mock() - - # Should not raise errors even with no backends - with patch("sunbeam.storage.registry.StorageBackendService"): - registry.register_cli_commands(mock_cli, mock_deployment) - - # Should still create the basic command groups - assert mock_cli.add_command.call_count >= 4 - - def test_command_registration_with_backend_errors(self, mock_deployment): - """Test command registration when backend registration fails.""" - registry = StorageBackendRegistry() - mock_backend = MockStorageBackend() - registry._backends = {"mock": mock_backend} - registry._loaded = True - - mock_cli = Mock() - - with ( - patch.object( - mock_backend, - "register_add_cli", - side_effect=Exception("Registration error"), - ), - patch.object( - mock_backend, - "register_cli", - side_effect=Exception("Registration error"), - ), - patch("sunbeam.storage.registry.StorageBackendService"), - ): - # Should not raise errors even if backend registration fails - registry.register_cli_commands(mock_cli, mock_deployment) - - # Should still create the basic command groups - assert mock_cli.add_command.call_count >= 4 diff --git a/sunbeam-python/tests/unit/sunbeam/storage/test_service.py b/sunbeam-python/tests/unit/sunbeam/storage/test_service.py index 73c52a884..b87d4f98c 100644 --- a/sunbeam-python/tests/unit/sunbeam/storage/test_service.py +++ b/sunbeam-python/tests/unit/sunbeam/storage/test_service.py @@ -1,206 +1,264 @@ # SPDX-FileCopyrightText: 2025 - Canonical Ltd # SPDX-License-Identifier: Apache-2.0 -"""Unit tests for storage backend service layer.""" +"""Tests for StorageBackendService class.""" + +from unittest.mock import Mock import pytest -from sunbeam.clusterd.service import ConfigItemNotFoundException +from sunbeam.clusterd.models import StorageBackend +from sunbeam.clusterd.service import StorageBackendNotFoundException from sunbeam.storage.models import ( + BackendAlreadyExistsException, BackendNotFoundException, + StorageBackendInfo, ) from sunbeam.storage.service import StorageBackendService -class TestStorageBackendService: - """Test cases for StorageBackendService class.""" +@pytest.fixture +def mock_deployment(): + """Create a mock deployment.""" + deployment = Mock() + deployment.openstack_machines_model = "openstack" + deployment.juju_controller = "test-controller" + deployment.get_client.return_value = Mock() + return deployment - def test_init(self, mock_deployment): - """Test service initialization.""" - service = StorageBackendService(mock_deployment) - assert service.deployment == mock_deployment - assert service.model == "admin/openstack" - assert service._tfvar_config_key == "TerraformVarsStorageBackends" +@pytest.fixture +def mock_jhelper(): + """Create a mock JujuHelper.""" + jhelper = Mock() + jhelper.get_model_name_with_owner.return_value = "admin/openstack" - def test_get_model_name_with_admin_prefix(self, mock_deployment): - """Test model name retrieval when model already has admin prefix.""" - mock_deployment.openstack_machines_model = "admin/openstack" - service = StorageBackendService(mock_deployment) + # Mock model status + mock_status = Mock() + mock_status.apps = {} + jhelper.get_model_status.return_value = mock_status - assert service.model == "admin/openstack" + return jhelper - def test_get_model_name_without_admin_prefix(self, mock_deployment): - """Test model name retrieval when missing admin prefix.""" - mock_deployment.openstack_machines_model = "openstack" - service = StorageBackendService(mock_deployment) - assert service.model == "admin/openstack" - - def test_list_backends_success(self, mock_deployment, sample_clusterd_config): - """Test successful backend listing.""" - import json - from unittest.mock import Mock, patch +@pytest.fixture +def service(mock_deployment, mock_jhelper): + """Create a StorageBackendService instance.""" + return StorageBackendService(mock_deployment, mock_jhelper) - mock_client = mock_deployment.get_client.return_value - # read_config expects JSON string, not dict - mock_client.cluster.get_config.return_value = json.dumps( - sample_clusterd_config["TerraformVarsStorageBackends"] - ) - # Mock JujuHelper and model status for dynamic status resolution - with patch("sunbeam.storage.service.JujuHelper") as mock_juju_helper: - # Create mock status objects - mock_app_status = Mock() - mock_app_status.app_status.current = "active" - mock_app_status.charm = "ch:amd64/jammy/cinder-volume-hitachi-123" +class TestStorageBackendService: + """Tests for StorageBackendService.""" - mock_model_status = Mock() - # App name is set to backend_name directly in Terraform - mock_model_status.apps = {"test-backend": mock_app_status} + def test_init(self, service, mock_deployment, mock_jhelper): + """Test service initialization.""" + assert service.deployment == mock_deployment + assert service.jhelper == mock_jhelper + assert service.model == "admin/openstack" + assert service._tfvar_config_key == "TerraformVarsStorageBackends" - mock_juju_instance = Mock() - mock_juju_instance.get_model_status.return_value = mock_model_status - mock_juju_helper.return_value = mock_juju_instance + def test_list_backends_empty(self, service, mock_deployment): + """Test listing backends when none exist.""" + mock_client = mock_deployment.get_client.return_value + mock_client.cluster.get_storage_backends.return_value.root = [] - service = StorageBackendService(mock_deployment) - backends = service.list_backends() + backends = service.list_backends() + assert backends == [] - assert len(backends) == 1 - assert backends[0].name == "test-backend" - assert backends[0].backend_type == "hitachi" - assert backends[0].status == "active" - assert backends[0].charm == "ch:amd64/jammy/cinder-volume-hitachi-123" + def test_list_backends_with_backends(self, service, mock_deployment, mock_jhelper): + """Test listing backends with some backends present.""" + # Create mock backend data + mock_backend = Mock(spec=StorageBackend) + mock_backend.name = "test-backend" + mock_backend.type = "test-type" + mock_backend.config = {"key": "value"} - def test_list_backends_no_config(self, mock_deployment): - """Test backend listing when no configuration exists.""" mock_client = mock_deployment.get_client.return_value - mock_client.cluster.get_config.side_effect = ConfigItemNotFoundException("test") + mock_client.cluster.get_storage_backends.return_value.root = [mock_backend] + + # Mock Juju status + mock_app_status = Mock() + mock_app_status.app_status.current = "active" + mock_app_status.charm = "test-charm" + + mock_status = Mock() + mock_status.apps = {"test-backend": mock_app_status} + mock_jhelper.get_model_status.return_value = mock_status - service = StorageBackendService(mock_deployment) backends = service.list_backends() - assert backends == [] + assert len(backends) == 1 + assert isinstance(backends[0], StorageBackendInfo) + assert backends[0].name == "test-backend" + assert backends[0].backend_type == "test-type" + assert backends[0].status == "active" + assert backends[0].charm == "test-charm" + assert backends[0].config == {"key": "value"} + + def test_list_backends_handles_errors(self, service, mock_deployment): + """Test that list_backends handles errors gracefully.""" + mock_backend = Mock(spec=StorageBackend) + mock_backend.name = "broken-backend" + mock_backend.type = "test-type" + # Make config access raise an error + mock_backend.config = property(lambda self: (_ for _ in ()).throw(ValueError())) - def test_list_backends_empty_config(self, mock_deployment): - """Test backend listing with empty configuration.""" mock_client = mock_deployment.get_client.return_value - mock_client.cluster.get_config.return_value = { - "TerraformVarsStorageBackends": {} - } + mock_client.cluster.get_storage_backends.return_value.root = [mock_backend] - service = StorageBackendService(mock_deployment) + # Should not raise, just skip the broken backend backends = service.list_backends() + assert len(backends) == 0 - assert backends == [] + def test_get_application_status_active(self, service, mock_jhelper): + """Test getting application status for active app.""" + mock_app_status = Mock() + mock_app_status.app_status.current = "active" - def test_list_backends_no_backends_key(self, mock_deployment): - """Test backend listing when backends key is missing.""" - mock_client = mock_deployment.get_client.return_value - mock_client.cluster.get_config.return_value = { - "TerraformVarsStorageBackends": {"model": "openstack"} - } + mock_status = Mock() + mock_status.apps = {"test-app": mock_app_status} + mock_jhelper.get_model_status.return_value = mock_status - service = StorageBackendService(mock_deployment) - backends = service.list_backends() + status = service._get_application_status(mock_jhelper, "test-app") + assert status == "active" - assert backends == [] + def test_get_application_status_not_found(self, service, mock_jhelper): + """Test getting application status for non-existent app.""" + mock_status = Mock() + mock_status.apps = {} + mock_jhelper.get_model_status.return_value = mock_status - def test_backend_exists_true(self, mock_deployment, sample_clusterd_config): - """Test backend existence check when backend exists.""" - import json + status = service._get_application_status(mock_jhelper, "nonexistent") + assert status == "not-found" - mock_client = mock_deployment.get_client.return_value - mock_client.cluster.get_config.return_value = json.dumps( - sample_clusterd_config["TerraformVarsStorageBackends"] - ) + def test_get_application_status_error(self, service, mock_jhelper): + """Test getting application status when Juju errors.""" + mock_jhelper.get_model_status.side_effect = Exception("Juju error") - service = StorageBackendService(mock_deployment) - exists = service.backend_exists("test-backend", "hitachi") + status = service._get_application_status(mock_jhelper, "test-app") + assert status == "unknown" - assert exists is True + def test_get_application_charm_success(self, service, mock_jhelper): + """Test getting application charm successfully.""" + mock_app_status = Mock() + mock_app_status.charm = "ch:amd64/focal/test-charm-123" - def test_backend_exists_false(self, mock_deployment, sample_clusterd_config): - """Test backend existence check when backend doesn't exist.""" - import json + mock_status = Mock() + mock_status.apps = {"test-app": mock_app_status} + mock_jhelper.get_model_status.return_value = mock_status - mock_client = mock_deployment.get_client.return_value - mock_client.cluster.get_config.return_value = json.dumps( - sample_clusterd_config["TerraformVarsStorageBackends"] - ) + charm = service._get_application_charm(mock_jhelper, "test-app") + assert charm == "ch:amd64/focal/test-charm-123" - service = StorageBackendService(mock_deployment) - result = service.backend_exists("nonexistent-backend", "hitachi") + def test_get_application_charm_not_found(self, service, mock_jhelper): + """Test getting charm for non-existent app.""" + mock_status = Mock() + mock_status.apps = {} + mock_jhelper.get_model_status.return_value = mock_status - assert result is False + charm = service._get_application_charm(mock_jhelper, "nonexistent") + assert charm == "Not Found" - def test_backend_exists_no_config(self, mock_deployment): - """Test backend existence check when no configuration exists.""" - from sunbeam.clusterd.service import ConfigItemNotFoundException + def test_get_application_charm_error(self, service, mock_jhelper): + """Test getting charm when Juju errors.""" + mock_jhelper.get_model_status.side_effect = Exception("Juju error") - mock_client = mock_deployment.get_client.return_value - mock_client.cluster.get_config.side_effect = ConfigItemNotFoundException( - "Config not found" - ) + charm = service._get_application_charm(mock_jhelper, "test-app") + assert charm == "Unknown" - service = StorageBackendService(mock_deployment) - result = service.backend_exists("test-backend", "hitachi") + def test_backend_exists_true(self, service, mock_deployment): + """Test checking if backend exists - true case.""" + mock_backend = Mock(spec=StorageBackend) + mock_backend.type = "test-type" - assert result is False + mock_client = mock_deployment.get_client.return_value + mock_client.cluster.get_storage_backend.return_value = mock_backend - def test_get_backend_config_success(self, mock_deployment, sample_clusterd_config): - """Test successful backend config retrieval.""" - import json + exists = service.backend_exists("test-backend", "test-type") + assert exists is True + def test_backend_exists_false(self, service, mock_deployment): + """Test checking if backend exists - false case.""" mock_client = mock_deployment.get_client.return_value - mock_client.cluster.get_config.return_value = json.dumps( - sample_clusterd_config["TerraformVarsStorageBackends"] + mock_client.cluster.get_storage_backend.side_effect = ( + StorageBackendNotFoundException() ) - service = StorageBackendService(mock_deployment) - config = service.get_backend_config("test-backend", "hitachi") - - assert isinstance(config, dict) - assert "hitachi-storage-id" in config - assert config["hitachi-storage-id"] == "123456" + exists = service.backend_exists("nonexistent", "test-type") + assert exists is False - def test_get_backend_config_not_found( - self, mock_deployment, sample_clusterd_config - ): - """Test backend config retrieval for non-existent backend.""" - import json + def test_backend_exists_type_mismatch(self, service, mock_deployment): + """Test checking backend exists with type mismatch.""" + mock_backend = Mock(spec=StorageBackend) + mock_backend.type = "different-type" mock_client = mock_deployment.get_client.return_value - mock_client.cluster.get_config.return_value = json.dumps( - sample_clusterd_config["TerraformVarsStorageBackends"] - ) + mock_client.cluster.get_storage_backend.return_value = mock_backend - service = StorageBackendService(mock_deployment) + with pytest.raises(BackendAlreadyExistsException): + service.backend_exists("test-backend", "expected-type") - with pytest.raises(BackendNotFoundException): - service.get_backend_config("nonexistent-backend", "hitachi") + def test_get_backend_success(self, service, mock_deployment): + """Test getting a backend successfully.""" + mock_backend = Mock(spec=StorageBackend) + mock_backend.name = "test-backend" + mock_backend.type = "test-type" + + mock_client = mock_deployment.get_client.return_value + mock_client.cluster.get_storage_backend.return_value = mock_backend - def test_get_backend_config_no_config(self, mock_deployment): - """Test backend config retrieval when no configuration exists.""" - from sunbeam.clusterd.service import ConfigItemNotFoundException + backend = service.get_backend("test-backend") + assert backend == mock_backend + def test_get_backend_not_found(self, service, mock_deployment): + """Test getting a non-existent backend.""" mock_client = mock_deployment.get_client.return_value - mock_client.cluster.get_config.side_effect = ConfigItemNotFoundException( - "Config not found" + mock_client.cluster.get_storage_backend.side_effect = ( + StorageBackendNotFoundException() ) - service = StorageBackendService(mock_deployment) - with pytest.raises(BackendNotFoundException): - service.get_backend_config("test-backend", "hitachi") + service.get_backend("nonexistent") + + def test_multiple_backends_different_types( + self, service, mock_deployment, mock_jhelper + ): + """Test listing multiple backends of different types.""" + mock_backend1 = Mock(spec=StorageBackend) + mock_backend1.name = "backend1" + mock_backend1.type = "type1" + mock_backend1.config = {} + + mock_backend2 = Mock(spec=StorageBackend) + mock_backend2.name = "backend2" + mock_backend2.type = "type2" + mock_backend2.config = {} - def test_error_handling_client_exception(self, mock_deployment): - """Test error handling when client raises exception.""" mock_client = mock_deployment.get_client.return_value - mock_client.cluster.get_config.side_effect = Exception("Client error") + mock_client.cluster.get_storage_backends.return_value.root = [ + mock_backend1, + mock_backend2, + ] + + # Mock Juju status for both apps + mock_app_status1 = Mock() + mock_app_status1.app_status.current = "active" + mock_app_status1.charm = "charm1" - service = StorageBackendService(mock_deployment) + mock_app_status2 = Mock() + mock_app_status2.app_status.current = "waiting" + mock_app_status2.charm = "charm2" + + mock_status = Mock() + mock_status.apps = {"backend1": mock_app_status1, "backend2": mock_app_status2} + mock_jhelper.get_model_status.return_value = mock_status - # The service logs the error but returns empty list instead of raising exception backends = service.list_backends() - assert backends == [] + + assert len(backends) == 2 + assert backends[0].name == "backend1" + assert backends[0].backend_type == "type1" + assert backends[0].status == "active" + assert backends[1].name == "backend2" + assert backends[1].backend_type == "type2" + assert backends[1].status == "waiting" diff --git a/sunbeam-python/tests/unit/sunbeam/storage/test_storage_base.py b/sunbeam-python/tests/unit/sunbeam/storage/test_storage_base.py deleted file mode 100644 index e4e3edde6..000000000 --- a/sunbeam-python/tests/unit/sunbeam/storage/test_storage_base.py +++ /dev/null @@ -1,296 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -"""Unit tests for storage backend base classes.""" - -from pathlib import Path -from unittest.mock import Mock, patch - -from sunbeam.storage.base import StorageBackendBase -from sunbeam.storage.models import StorageBackendConfig -from sunbeam.storage.service import StorageBackendService - - -class MockStorageBackend(StorageBackendBase): - """Mock storage backend for testing.""" - - name = "mock" - display_name = "Mock Storage Backend" - charm_name = "mock-charm" - - def __init__(self): - super().__init__() - - @property - def tfvar_config_key(self): - """Config key for storing Terraform variables in clusterd.""" - return f"TerraformVars{self.name.title()}Backend" - - @property - def config_class(self): - return StorageBackendConfig - - def get_terraform_variables( - self, backend_name: str, config: StorageBackendConfig, model: str - ): - return { - "model": model, - "mock_backends": { - backend_name: { - "backend_type": self.name, - "charm_name": self.charm_name, - "charm_channel": self.charm_channel, - "backend_config": config.model_dump(), - "backend_endpoint": self.backend_endpoint, - "units": self.units, - "additional_integrations": self.additional_integrations, - } - }, - } - - def commands(self, conditions=None): - """Return command mapping for this backend.""" - return {} - - def prompt_for_config(self, backend_name: str): - """Mock implementation of prompt_for_config.""" - return StorageBackendConfig(name=backend_name) - - def create_deploy_step( - self, - deployment, - client, - tfhelper, - jhelper, - manifest, - backend_name, - config, - model, - ): - """Create a deployment step for this backend.""" - from sunbeam.storage.steps import BaseStorageBackendDeployStep - - class MockDeployStep(BaseStorageBackendDeployStep): - def get_terraform_variables(self): - return self.backend_instance.get_terraform_variables( - self.backend_name, self.backend_config, self.model - ) - - return MockDeployStep( - deployment, - client, - tfhelper, - jhelper, - manifest, - backend_name, - config, - self, - model, - ) - - def create_destroy_step( - self, deployment, client, tfhelper, jhelper, manifest, backend_name, model - ): - """Create a destruction step for this backend.""" - from sunbeam.storage.steps import BaseStorageBackendDestroyStep - - class MockDestroyStep(BaseStorageBackendDestroyStep): - def get_terraform_variables(self): - return self.backend_instance.get_terraform_variables( - self.backend_name, - StorageBackendConfig(name=self.backend_name), - self.model, - ) - - return MockDestroyStep( - deployment, client, tfhelper, jhelper, manifest, backend_name, self, model - ) - - def create_update_config_step(self, deployment, backend_name, config_updates): - """Create a configuration update step for this backend.""" - from sunbeam.storage.steps import BaseStorageBackendConfigUpdateStep - - return BaseStorageBackendConfigUpdateStep( - deployment, self, backend_name, config_updates - ) - - def register_add_cli(self, add): - """Mock CLI registration.""" - pass - - def register_cli( - self, remove, config_show, config_set, config_reset, config_options, deployment - ): - """Mock CLI registration.""" - pass - - def _get_default_config(self) -> StorageBackendConfig: - """Get a default configuration instance for comparison.""" - return StorageBackendConfig(name="default") - - -class TestStorageBackendBase: - """Test cases for StorageBackendBase class.""" - - def test_init(self): - """Test backend initialization.""" - backend = MockStorageBackend() - - assert backend.name == "mock" - assert backend.display_name == "Mock Storage Backend" - assert backend.charm_name == "mock-charm" - assert backend.tfplan == "storage-backend-plan" - assert backend.tfplan_dir == "deploy-storage-backend" - assert backend.charm_channel == "stable" - assert backend.charm_base == "ubuntu@22.04" - assert backend.backend_endpoint == "cinder-volume" - assert backend.units == 1 - assert backend.additional_integrations == [] - - def test_config_class(self): - """Test configuration class retrieval.""" - backend = MockStorageBackend() - config_class = backend.config_class - assert config_class == StorageBackendConfig - - def test_get_terraform_variables(self): - """Test Terraform variables generation.""" - backend = MockStorageBackend() - config = StorageBackendConfig(name="test-backend") - - variables = backend.get_terraform_variables("test-backend", config, "openstack") - - assert "model" in variables - assert "mock_backends" in variables - assert variables["model"] == "openstack" - assert "test-backend" in variables["mock_backends"] - - backend_config = variables["mock_backends"]["test-backend"] - assert backend_config["backend_type"] == "mock" - assert backend_config["charm_name"] == "mock-charm" - assert backend_config["charm_channel"] == "stable" - assert backend_config["backend_endpoint"] == "cinder-volume" - assert backend_config["units"] == 1 - - # todo: fix this implementation. the get service is not part of the base class - @patch("sunbeam.storage.base.StorageBackendService") - def test_get_service(self, mock_service_class, mock_deployment): - """Test service creation and caching.""" - backend = MockStorageBackend() - mock_service = Mock(spec=StorageBackendService) - mock_service_class.return_value = mock_service - - # First call should create service - service1 = backend._get_service(mock_deployment) - assert service1 == mock_service - mock_service_class.assert_called_once_with(mock_deployment) - - # Second call should return cached service - service2 = backend._get_service(mock_deployment) - assert service2 == mock_service - # Should not call constructor again - assert mock_service_class.call_count == 1 - - def test_prompt_for_config(self, mock_deployment): - """Test configuration prompting (base implementation).""" - backend = MockStorageBackend() - - # Base implementation should return empty config - config = backend.prompt_for_config("test-backend") - assert isinstance(config, StorageBackendConfig) - assert config.name == "test-backend" - - def test_abstract_methods_not_implemented(self): - """Test that abstract methods raise NotImplementedError in base class.""" - # This test verifies that StorageBackendBase is properly abstract - # We can't instantiate it directly, but we can test that our mock - # implements required methods - backend = MockStorageBackend() - - # These methods should be implemented in the mock - assert hasattr(backend, "config_class") - assert hasattr(backend, "get_terraform_variables") - assert callable(backend.config_class) - assert callable(backend.get_terraform_variables) - - def test_version_property(self): - """Test version property.""" - backend = MockStorageBackend() - assert hasattr(backend, "version") - # Version should be set in base class - assert str(backend.version) == "0.0.1" - - def test_tf_plan_location_property(self): - """Test Terraform plan location property.""" - backend = MockStorageBackend() - assert backend.tf_plan_location == "FEATURE_REPO" - - def test_user_manifest_property(self): - """Test user manifest property.""" - backend = MockStorageBackend() - assert backend.user_manifest is None - - @patch("sunbeam.storage.base.Path") - def test_tfplan_path_property(self, mock_path): - """Test Terraform plan path property.""" - backend = MockStorageBackend() - mock_path.return_value = Path("/test/path") - - # This would test the tfplan_path property if it exists - # The actual implementation may vary - assert hasattr(backend, "tfplan_dir") - assert backend.tfplan_dir == "deploy-storage-backend" - - def test_backend_attributes(self): - """Test that all required backend attributes are set.""" - backend = MockStorageBackend() - - # Test required string attributes - required_attrs = ["name", "display_name", "charm_name", "tfplan", "tfplan_dir"] - for attr in required_attrs: - assert hasattr(backend, attr) - assert isinstance(getattr(backend, attr), str) - assert getattr(backend, attr) != "" - - # Test optional attributes - optional_attrs = [ - "charm_channel", - "charm_revision", - "charm_base", - "backend_endpoint", - ] - for attr in optional_attrs: - assert hasattr(backend, attr) - - # Test integer attributes - assert hasattr(backend, "units") - assert isinstance(backend.units, int) - assert backend.units > 0 - - # Test list attributes - assert hasattr(backend, "additional_integrations") - assert isinstance(backend.additional_integrations, list) - - def test_service_caching_with_different_deployments(self, mock_deployment): - """Test service caching behavior with different deployment objects.""" - backend = MockStorageBackend() - - with patch("sunbeam.storage.base.StorageBackendService") as mock_service_class: - mock_service1 = Mock(spec=StorageBackendService) - mock_service_class.return_value = mock_service1 - - # First deployment - service1 = backend._get_service(mock_deployment) - assert service1 == mock_service1 - - # Different deployment object should return same cached service - mock_deployment2 = Mock() - service2 = backend._get_service(mock_deployment2) - assert service2 == mock_service1 # Same service is cached and reused - - # Same deployment should return cached service - service1_cached = backend._get_service(mock_deployment) - assert service1_cached == mock_service1 - - # Should have called constructor only once (service is cached) - assert mock_service_class.call_count == 1 diff --git a/sunbeam-python/tests/unit/sunbeam/storage/test_storage_steps.py b/sunbeam-python/tests/unit/sunbeam/storage/test_storage_steps.py deleted file mode 100644 index a171cc6d2..000000000 --- a/sunbeam-python/tests/unit/sunbeam/storage/test_storage_steps.py +++ /dev/null @@ -1,594 +0,0 @@ -# SPDX-FileCopyrightText: 2025 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -"""Unit tests for Terraform-based storage backend step classes.""" - -from unittest.mock import Mock, patch - -from sunbeam.core.common import ResultType -from sunbeam.storage.base import StorageBackendBase -from sunbeam.storage.models import StorageBackendConfig -from sunbeam.storage.steps import ( - BaseStorageBackendConfigUpdateStep, - BaseStorageBackendDeployStep, - BaseStorageBackendDestroyStep, -) - - -class MockStorageBackend(StorageBackendBase): - """Mock storage backend for testing.""" - - name = "mock" - display_name = "Mock Storage" - description = "Mock storage backend for testing" - terraform_plan_location = "/path/to/mock/terraform" - charm_name = "mock-charm" - - def __init__(self, deployment=None): - super().__init__() - self.deployment = deployment - - def _get_default_config(self) -> dict: - """Get default configuration for mock backend.""" - return {"mock_key": "default_value"} - - def prompt_for_config(self) -> dict: - """Mock prompt for config.""" - return {"mock_key": "mock_value"} - - @property - def tfvar_config_key(self): - """Config key for storing Terraform variables in clusterd.""" - return f"TerraformVars{self.name.title()}Backend" - - def register_add_cli(self, add): - """Mock CLI registration.""" - pass - - def register_cli( - self, remove, config_show, config_set, config_reset, config_options, deployment - ): - """Mock CLI registration.""" - pass - - def get_terraform_variables(self, backend_name, config, model): - """Mock Terraform variables.""" - return { - "model": model, - "mock_backends": { - backend_name: { - "backend_type": self.name, - "charm_name": self.charm_name, - "charm_channel": "latest/stable", - "backend_config": config.model_dump(), - "backend_endpoint": "storage-backend", - "units": 1, - "additional_integrations": {}, - } - }, - } - - def create_deploy_step( - self, - deployment, - client, - tfhelper, - jhelper, - manifest, - backend_name, - backend_config, - model, - ): - """Create a mock deployment step.""" - return MockDeployStep( - deployment, - client, - tfhelper, - jhelper, - manifest, - backend_name, - backend_config, - self, - model, - ) - - def create_destroy_step( - self, deployment, client, tfhelper, jhelper, manifest, backend_name, model - ): - """Create a mock destruction step.""" - return MockDestroyStep( - deployment, client, tfhelper, jhelper, manifest, backend_name, self, model - ) - - def create_update_config_step(self, deployment, backend_name, config_updates): - """Create a mock configuration update step.""" - return BaseStorageBackendConfigUpdateStep( - deployment, self, backend_name, config_updates - ) - - def prompt_for_config(self, backend_name: str) -> StorageBackendConfig: # noqa: F811 - """Mock prompt for configuration.""" - return StorageBackendConfig(name=backend_name) - - def get_field_mapping(self): - """Return field mapping for mock backend.""" - return {} - - def commands(self): - """Return commands for mock backend.""" - return [] - - -class MockDeployStep(BaseStorageBackendDeployStep): - """Concrete implementation of BaseStorageBackendDeployStep for testing.""" - - def get_terraform_variables(self) -> dict: - """Get terraform variables for deployment.""" - return self.backend_instance.get_terraform_variables( - self.backend_name, self.backend_config, self.model - ) - - -class MockDestroyStep(BaseStorageBackendDestroyStep): - """Concrete implementation of BaseStorageBackendDestroyStep for testing.""" - - pass - - -class TestBaseStorageBackendDeployStep: - """Test cases for BaseStorageBackendDeployStep.""" - - def test_init( - self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest - ): - """Test step initialization.""" - backend_instance = MockStorageBackend() - backend_name = "test-backend" - backend_config = StorageBackendConfig(name=backend_name) - model = "openstack" - - step = MockDeployStep( - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - backend_name, - backend_config, - backend_instance, - model, - ) - - assert step.deployment == mock_deployment - assert step.client == mock_client - assert step.tfhelper == mock_tfhelper - assert step.jhelper == mock_jhelper - assert step.manifest == mock_manifest - assert step.backend_name == backend_name - assert step.backend_config == backend_config - assert step.backend_instance == backend_instance - assert step.model == model - assert "Deploy Mock Storage backend" in step.name - assert "test-backend" in step.description - - @patch("sunbeam.storage.steps.read_config") - @patch("sunbeam.storage.steps.update_config") - def test_run_success( - self, - mock_update_config, - mock_read_config, - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - ): - """Test successful deployment run.""" - # Mock existing config - mock_read_config.return_value = {"existing": "config"} - - # Mock Terraform operations - mock_tfhelper.update_tfvars_and_apply_tf.return_value = None - - backend_instance = MockStorageBackend() - backend_name = "test-backend" - backend_config = StorageBackendConfig(name=backend_name) - model = "openstack" - - step = MockDeployStep( - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - backend_name, - backend_config, - backend_instance, - model, - ) - - result = step.run() - - assert result.result_type == ResultType.COMPLETED - - # Verify Terraform was called with correct variables - mock_tfhelper.update_tfvars_and_apply_tf.assert_called_once() - call_args = mock_tfhelper.update_tfvars_and_apply_tf.call_args - tfvars = call_args[1]["override_tfvars"] - - assert "model" in tfvars - assert "mock_backends" in tfvars - assert tfvars["model"] == "openstack" - - def test_get_application_timeout( - self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest - ): - """Test application timeout retrieval.""" - backend_instance = MockStorageBackend() - backend_name = "test-backend" - backend_config = StorageBackendConfig(name=backend_name) - model = "openstack" - - step = MockDeployStep( - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - backend_name, - backend_config, - backend_instance, - model, - ) - - timeout = step.get_application_timeout() - assert timeout == 1200 # Default timeout - - -class TestBaseStorageBackendDestroyStep: - """Test cases for BaseStorageBackendDestroyStep.""" - - def test_init( - self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest - ): - """Test step initialization.""" - backend_instance = MockStorageBackend() - backend_name = "test-backend" - model = "openstack" - - step = BaseStorageBackendDestroyStep( - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - backend_name, - backend_instance, - model, - ) - - assert step.deployment == mock_deployment - assert step.client == mock_client - assert step.tfhelper == mock_tfhelper - assert step.jhelper == mock_jhelper - assert step.manifest == mock_manifest - assert step.backend_name == backend_name - assert step.backend_instance == backend_instance - assert step.model == model - assert "Destroy Mock Storage backend" in step.name - assert "test-backend" in step.description - - @patch("sunbeam.storage.steps.read_config") - @patch("sunbeam.storage.steps.update_config") - def test_run_success_partial_destroy( - self, - mock_update_config, - mock_read_config, - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - ): - """Test successful destruction run with partial destroy.""" - # Mock existing config with multiple backends - mock_read_config.return_value = { - "mock_backends": { - "test-backend": {"backend_type": "mock"}, - "other-backend": {"backend_type": "hitachi"}, - } - } - - # Mock Terraform operations - mock_tfhelper.apply.return_value = None - - backend_instance = MockStorageBackend() - backend_name = "test-backend" - model = "openstack" - - step = BaseStorageBackendDestroyStep( - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - backend_name, - backend_instance, - model, - ) - - # Mock should_destroy_all_resources to return False - with patch.object(step, "should_destroy_all_resources", return_value=False): - result = step.run() - - assert result.result_type == ResultType.COMPLETED - - # Verify Terraform was called - mock_tfhelper.apply.assert_called_once() - - @patch("sunbeam.storage.steps.read_config") - def test_run_success_full_destroy( - self, - mock_read_config, - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - ): - """Test successful destruction run with full destroy.""" - # Mock existing config with only one backend - mock_read_config.return_value = { - "mock_backends": {"test-backend": {"backend_type": "mock"}} - } - - # Mock Terraform operations - mock_tfhelper.destroy.return_value = None - - backend_instance = MockStorageBackend() - backend_name = "test-backend" - model = "openstack" - - step = BaseStorageBackendDestroyStep( - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - backend_name, - backend_instance, - model, - ) - - # Mock should_destroy_all_resources to return True - with patch.object(step, "should_destroy_all_resources", return_value=True): - result = step.run() - - assert result.result_type == ResultType.COMPLETED - - # Verify Terraform destroy was called - mock_tfhelper.destroy.assert_called_once() - - @patch("sunbeam.storage.steps.read_config") - def test_should_destroy_all_resources_true( - self, - mock_read_config, - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - ): - """Test should_destroy_all_resources logic when only one backend exists.""" - # Mock config with only one backend - mock_read_config.return_value = { - "mock_backends": {"test-backend": {"backend_type": "mock"}} - } - - backend_instance = MockStorageBackend() - backend_name = "test-backend" - model = "openstack" - - step = BaseStorageBackendDestroyStep( - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - backend_name, - backend_instance, - model, - ) - - result = step.should_destroy_all_resources() - assert result is True - - @patch("sunbeam.storage.steps.read_config") - def test_should_destroy_all_resources_false( - self, - mock_read_config, - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - ): - """Test should_destroy_all_resources logic when multiple backends exist.""" - # Mock config with multiple backends - mock_read_config.return_value = { - "mock_backends": { - "test-backend": {"backend_type": "mock"}, - "other-backend": {"backend_type": "hitachi"}, - } - } - - backend_instance = MockStorageBackend() - backend_name = "test-backend" - model = "openstack" - - step = BaseStorageBackendDestroyStep( - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - backend_name, - backend_instance, - model, - ) - - result = step.should_destroy_all_resources() - assert result is False - - def test_get_application_timeout( - self, mock_deployment, mock_client, mock_tfhelper, mock_jhelper, mock_manifest - ): - """Test application timeout retrieval.""" - backend_instance = MockStorageBackend() - backend_name = "test-backend" - model = "openstack" - - step = BaseStorageBackendDestroyStep( - mock_deployment, - mock_client, - mock_tfhelper, - mock_jhelper, - mock_manifest, - backend_name, - backend_instance, - model, - ) - - timeout = step.get_application_timeout() - assert timeout == 1200 # Default timeout - - -class TestBaseStorageBackendConfigUpdateStep: - """Test cases for BaseStorageBackendConfigUpdateStep.""" - - def test_init(self, mock_deployment): - """Test step initialization.""" - backend_instance = MockStorageBackend() - backend_name = "test-backend" - config_updates = {"key1": "value1", "key2": "value2"} - - # Mock deployment methods - mock_client = Mock() - mock_tfhelper = Mock() - mock_deployment.get_client.return_value = mock_client - mock_deployment.get_tfhelper.return_value = mock_tfhelper - - step = BaseStorageBackendConfigUpdateStep( - mock_deployment, backend_instance, backend_name, config_updates - ) - - assert step.deployment == mock_deployment - assert step.backend_instance == backend_instance - assert step.backend_name == backend_name - assert step.config_updates == config_updates - assert step.client == mock_client - assert step.tfhelper == mock_tfhelper - assert "Update Mock Storage backend" in step.name - assert "test-backend" in step.description - - @patch("sunbeam.storage.steps.read_config") - @patch("sunbeam.storage.steps.update_config") - def test_run_update_operation( - self, mock_update_config, mock_read_config, mock_deployment - ): - """Test configuration update operation.""" - # Mock existing config - mock_read_config.return_value = { - "mock_backends": { - "test-backend": {"backend_config": {"existing_key": "existing_value"}} - } - } - - backend_instance = MockStorageBackend() - backend_name = "test-backend" - config_updates = {"key1": "value1", "key2": "value2"} - - # Mock deployment methods - mock_client = Mock() - mock_tfhelper = Mock() - mock_tfhelper.write_tfvars.return_value = None - mock_tfhelper.apply.return_value = None - mock_deployment.get_client.return_value = mock_client - mock_deployment.get_tfhelper.return_value = mock_tfhelper - - step = BaseStorageBackendConfigUpdateStep( - mock_deployment, backend_instance, backend_name, config_updates - ) - - result = step.run() - - assert result.result_type == ResultType.COMPLETED - - # Verify Terraform was called - mock_tfhelper.apply.assert_called_once() - - @patch("sunbeam.storage.steps.read_config") - @patch("sunbeam.storage.steps.update_config") - def test_run_reset_operation( - self, mock_update_config, mock_read_config, mock_deployment - ): - """Test configuration reset operation.""" - # Mock existing config - mock_read_config.return_value = { - "mock_backends": { - "test-backend": {"backend_config": {"key1": "value1", "key2": "value2"}} - } - } - - backend_instance = MockStorageBackend() - backend_name = "test-backend" - config_updates = {"_reset_keys": ["key1"]} - - # Mock deployment methods - mock_client = Mock() - mock_tfhelper = Mock() - mock_tfhelper.write_tfvars.return_value = None - mock_tfhelper.apply.return_value = None - mock_deployment.get_client.return_value = mock_client - mock_deployment.get_tfhelper.return_value = mock_tfhelper - - step = BaseStorageBackendConfigUpdateStep( - mock_deployment, backend_instance, backend_name, config_updates - ) - - result = step.run() - - assert result.result_type == ResultType.COMPLETED - - # Verify Terraform was called - mock_tfhelper.apply.assert_called_once() - - def test_handle_update_operation(self, mock_deployment): - """Test update operation handling.""" - backend_instance = MockStorageBackend() - backend_name = "test-backend" - config_updates = {"key1": "value1", "key2": "value2"} - - step = BaseStorageBackendConfigUpdateStep( - mock_deployment, backend_instance, backend_name, config_updates - ) - - current_config = { - "mock_backends": { - "test-backend": {"charm_config": {"existing_key": "existing_value"}} - } - } - updated_config = step.handle_update_operation(current_config) - - expected_config = { - "mock_backends": { - "test-backend": { - "charm_config": { - "existing_key": "existing_value", - "key1": "value1", - "key2": "value2", - } - } - } - } - assert updated_config == expected_config From 5903959c786c5205dbd8f6d62517381e915c4e03 Mon Sep 17 00:00:00 2001 From: Guillaume Boutry Date: Wed, 22 Oct 2025 14:04:31 +0200 Subject: [PATCH 3/4] feat(storage): validate backend questions using pydantic Validate interactive questions from the user using pydantic. Merge generate question functions as they have the same purpose. Signed-off-by: Guillaume Boutry --- sunbeam-python/sunbeam/storage/steps.py | 71 +++++++++----- .../tests/unit/sunbeam/storage/test_steps.py | 96 +++++++++++++++++++ 2 files changed, 143 insertions(+), 24 deletions(-) create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/test_steps.py diff --git a/sunbeam-python/sunbeam/storage/steps.py b/sunbeam-python/sunbeam/storage/steps.py index 6f36cacb7..3bdb21dfd 100644 --- a/sunbeam-python/sunbeam/storage/steps.py +++ b/sunbeam-python/sunbeam/storage/steps.py @@ -9,7 +9,7 @@ """ import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Callable import pydantic import tenacity @@ -183,36 +183,56 @@ def run(self, status: Status | None = None) -> Result: return Result(ResultType.FAILED, str(e)) -def generate_required_questions_from_config( - config_type: type[pydantic.BaseModel], -) -> dict[str, Question]: - questions = {} # type: ignore - for field, finfo in config_type.model_fields.items(): - if not finfo.is_required(): - continue - question_type: type[Question] = PromptQuestion - for constraint in finfo.metadata: - if isinstance(constraint, SecretDictField): - question_type = PasswordPromptQuestion - questions[field] = question_type( - f"Enter value for {field!r}", description=finfo.description - ) - return questions +def basemodel_validator( + model: type[pydantic.BaseModel], +) -> Callable[[str], Callable[[Any], None]]: + """Return a factory producing value validators for Pydantic model fields.""" + validator = model.__pydantic_validator__ + fields = dict(model.model_fields.items()) + constructed = model.model_construct() + + def field_validator(field: str) -> Callable[[Any], None]: + if field not in fields: + raise ValueError(f"{model.__name__} has no field named {field!r}") + + def value_validator(value: Any) -> None: + try: + validator.validate_assignment(constructed, field, value) + except pydantic.ValidationError as exc: + messages: list[str] = [] + for error in exc.errors(): + location = ".".join(str(part) for part in error.get("loc", ())) + message = error.get("msg", str(error)) + if location: + messages.append(f"{location}: {message}") + else: + messages.append(message) + raise ValueError("; ".join(messages)) + return value_validator -def generate_optional_questions_from_config( - config_type: type[pydantic.BaseModel], + return field_validator + + +def generate_questions_from_config( + config_type: type[pydantic.BaseModel], *, optional: bool = False ) -> dict[str, Question]: questions = {} # type: ignore + field_validator = basemodel_validator(config_type) for field, finfo in config_type.model_fields.items(): - if finfo.is_required(): + if optional and finfo.is_required(): + continue + if not optional and not finfo.is_required(): continue question_type: type[Question] = PromptQuestion for constraint in finfo.metadata: if isinstance(constraint, SecretDictField): question_type = PasswordPromptQuestion + prompt_suffix = " (optional)" if optional else "" questions[field] = question_type( - f"Enter value for {field!r} (optional)", description=finfo.description + f"Enter value for {field!r}{prompt_suffix}", + description=finfo.description, + validation_function=field_validator(field), ) return questions @@ -286,7 +306,7 @@ def prompt( manifest_configured = True required_questions_bank = QuestionBank( - questions=generate_required_questions_from_config( + questions=generate_questions_from_config( self.backend_instance.config_type() ), console=console, @@ -296,7 +316,10 @@ def prompt( show_hint=show_hint, ) for name, question in required_questions_bank.questions.items(): - self.variables[name] = question.ask() + answer = question.ask() + while not answer: + answer = question.ask() + self.variables[name] = answer res = ConfirmQuestion( "Set optional configurations?", @@ -309,8 +332,8 @@ def prompt( return optional_questions_bank = QuestionBank( - questions=generate_optional_questions_from_config( - self.backend_instance.config_type() + questions=generate_questions_from_config( + self.backend_instance.config_type(), optional=True ), console=console, preseed=preseed, diff --git a/sunbeam-python/tests/unit/sunbeam/storage/test_steps.py b/sunbeam-python/tests/unit/sunbeam/storage/test_steps.py new file mode 100644 index 000000000..80015583a --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/test_steps.py @@ -0,0 +1,96 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +from typing import Annotated + +import pydantic +import pytest + +from sunbeam.core.questions import PasswordPromptQuestion, PromptQuestion +from sunbeam.storage.models import SecretDictField +from sunbeam.storage.steps import basemodel_validator, generate_questions_from_config + + +class SampleConfig(pydantic.BaseModel): + required_field: Annotated[ + int, + pydantic.Field(ge=1, description="A positive integer"), + ] + secret_field: Annotated[ + str, + pydantic.Field(description="A secret value"), + SecretDictField(field="secret"), + ] + optional_field: Annotated[ + int | None, + pydantic.Field(ge=0, description="Optional value"), + ] = None + + @pydantic.field_validator("secret_field") + @classmethod + def no_digits(cls, value: str) -> str: + if any(ch.isdigit() for ch in value): + raise ValueError("must not contain digits") + return value + + @pydantic.model_validator(mode="after") + def disallow_thirteen(self): + if getattr(self, "required_field", None) == 13: + raise ValueError("thirteen is not allowed") + return self + + +class TestBasemodelValidator: + def test_valid_and_invalid_values(self): + field_validator = basemodel_validator(SampleConfig) + + # Valid value should pass without raising + field_validator("required_field")(10) + + # Root validator error should be surfaced as ValueError + with pytest.raises(ValueError, match="thirteen is not allowed"): + field_validator("required_field")(13) + + # Field-level validation should be applied + with pytest.raises(ValueError, match="must not contain digits"): + field_validator("secret_field")("password1") + + # Type enforcement should be handled by pydantic + with pytest.raises(ValueError): + field_validator("required_field")("not-an-int") + + def test_unknown_field_raises_value_error(self): + field_validator = basemodel_validator(SampleConfig) + with pytest.raises(ValueError, match="has no field named"): + field_validator("missing") + + +class TestGenerateQuestionsFromConfig: + def test_required_questions_include_validation(self): + questions = generate_questions_from_config(SampleConfig) + + assert set(questions.keys()) == {"required_field", "secret_field"} + assert all( + isinstance(question, (PromptQuestion, PasswordPromptQuestion)) + for question in questions.values() + ) + + secret_question = questions["secret_field"] + assert isinstance(secret_question, PasswordPromptQuestion) + with pytest.raises(ValueError, match="must not contain digits"): + secret_question.validation_function("password1") # type: ignore[arg-type] + + required_question = questions["required_field"] + assert required_question.validation_function is not None + with pytest.raises(ValueError): + required_question.validation_function("bad") # type: ignore[arg-type] + + def test_optional_questions_include_validation(self): + questions = generate_questions_from_config(SampleConfig, optional=True) + + assert set(questions.keys()) == {"optional_field"} + optional_question = questions["optional_field"] + assert optional_question.validation_function is not None + optional_question.validation_function(5) # type: ignore[arg-type] + with pytest.raises(ValueError): + optional_question.validation_function(-1) # type: ignore[arg-type] From 141e58a7f8c60bbfa98168d808bb7e443b0a25bc Mon Sep 17 00:00:00 2001 From: Guillaume Boutry Date: Wed, 22 Oct 2025 16:37:20 +0200 Subject: [PATCH 4/4] refactor(storage): drop unused backend properties Signed-off-by: Guillaume Boutry --- .../storage/backends/dellsc/backend.py | 15 --------------- .../storage/backends/hitachi/backend.py | 15 --------------- .../storage/backends/purestorage/backend.py | 15 --------------- sunbeam-python/sunbeam/storage/base.py | 19 ++----------------- sunbeam-python/sunbeam/storage/manager.py | 2 +- .../sunbeam/storage/backends/test_common.py | 16 ---------------- .../tests/unit/sunbeam/storage/conftest.py | 4 ---- .../tests/unit/sunbeam/storage/test_base.py | 15 --------------- 8 files changed, 3 insertions(+), 98 deletions(-) diff --git a/sunbeam-python/sunbeam/storage/backends/dellsc/backend.py b/sunbeam-python/sunbeam/storage/backends/dellsc/backend.py index 616885743..743cf14bd 100644 --- a/sunbeam-python/sunbeam/storage/backends/dellsc/backend.py +++ b/sunbeam-python/sunbeam/storage/backends/dellsc/backend.py @@ -149,21 +149,6 @@ def charm_base(self) -> str: """Return the charm base for this backend.""" return "ubuntu@24.04" - @property - def backend_endpoint(self) -> str: - """Return the backend endpoint for this backend.""" - return "cinder-volume" - - @property - def units(self) -> int: - """Return the number of units for this backend.""" - return 1 - - @property - def additional_integrations(self) -> list[str]: - """Return a list of additional integrations for this backend.""" - return [] - def config_type(self) -> type[StorageBackendConfig]: """Return the configuration class for Dell Storage Center backend.""" return DellSCConfig diff --git a/sunbeam-python/sunbeam/storage/backends/hitachi/backend.py b/sunbeam-python/sunbeam/storage/backends/hitachi/backend.py index 56ba12462..f41f8a3ec 100644 --- a/sunbeam-python/sunbeam/storage/backends/hitachi/backend.py +++ b/sunbeam-python/sunbeam/storage/backends/hitachi/backend.py @@ -298,21 +298,6 @@ def charm_base(self) -> str: """Return the charm base for this backend.""" return "ubuntu@24.04" - @property - def backend_endpoint(self) -> str: - """Return the backend endpoint for this backend.""" - return "cinder-volume" - - @property - def units(self) -> int: - """Return the number of units for this backend.""" - return 1 - - @property - def additional_integrations(self) -> list[str]: - """Return a list of additional integrations for this backend.""" - return [] - def config_type(self) -> type[StorageBackendConfig]: """Return the configuration class for Hitachi backend.""" return HitachiConfig diff --git a/sunbeam-python/sunbeam/storage/backends/purestorage/backend.py b/sunbeam-python/sunbeam/storage/backends/purestorage/backend.py index c7ed1691a..a77fea55d 100644 --- a/sunbeam-python/sunbeam/storage/backends/purestorage/backend.py +++ b/sunbeam-python/sunbeam/storage/backends/purestorage/backend.py @@ -172,21 +172,6 @@ def charm_base(self) -> str: """Return the charm base for this backend.""" return "ubuntu@24.04" - @property - def backend_endpoint(self) -> str: - """Return the backend endpoint for this backend.""" - return "cinder-volume" - - @property - def units(self) -> int: - """Return the number of units for this backend.""" - return 1 - - @property - def additional_integrations(self) -> list[str]: - """Return a list of additional integrations for this backend.""" - return [] - def config_type(self) -> type[StorageBackendConfig]: """Return the configuration class for Pure Storage backend.""" return PureStorageConfig diff --git a/sunbeam-python/sunbeam/storage/base.py b/sunbeam-python/sunbeam/storage/base.py index c1367e11e..757e7e7b8 100644 --- a/sunbeam-python/sunbeam/storage/base.py +++ b/sunbeam-python/sunbeam/storage/base.py @@ -96,10 +96,10 @@ class StorageBackendBase(typing.Generic[BackendConfig]): display_name: str = "Base Storage Backend" version = Version("0.0.1") user_manifest = None # Path to user manifest file - # By default, any storage backend is considered beta risk. + # By default, any storage backend is considered edge risk. # It will be needed to override in subclasses if the backend is # considered stable. - risk_availability: RiskLevel = RiskLevel.BETA + risk_availability: RiskLevel = RiskLevel.EDGE def __init__(self) -> None: """Initialize storage backend.""" @@ -505,21 +505,6 @@ def charm_base(self) -> str: """Charm base for this backend.""" return "ubuntu@22.04" - @property - def backend_endpoint(self) -> str: - """Backend endpoint name for integration.""" - return "cinder-volume" - - @property - def units(self) -> int: - """Number of units to deploy.""" - return 1 - - @property - def additional_integrations(self) -> list[str]: - """Additional integrations for this backend.""" - return [] - @property def principal_application(self) -> str: """Principal application for this backend. diff --git a/sunbeam-python/sunbeam/storage/manager.py b/sunbeam-python/sunbeam/storage/manager.py index 776c4f405..0d1c5783d 100644 --- a/sunbeam-python/sunbeam/storage/manager.py +++ b/sunbeam-python/sunbeam/storage/manager.py @@ -146,7 +146,7 @@ def register_cli_commands( ) -> None: """Register all backend commands with the storage CLI group. - This now follows the provider pattern: create stable top-level groups + This follows the provider pattern: create stable top-level groups and let each backend self-register its subcommands under those groups. The CLI UX remains the same, e.g.: sunbeam storage add [...] 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 43062b198..dc83c98d8 100644 --- a/sunbeam-python/tests/unit/sunbeam/storage/backends/test_common.py +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_common.py @@ -76,22 +76,6 @@ def test_charm_base_is_set(self, backend): assert backend.charm_base assert isinstance(backend.charm_base, str) - def test_backend_endpoint_is_set(self, backend): - """Test that backend_endpoint property is set.""" - assert backend.backend_endpoint - assert isinstance(backend.backend_endpoint, str) - - def test_units_is_positive(self, backend): - """Test that units property returns a positive integer.""" - assert isinstance(backend.units, int) - assert backend.units > 0 - - def test_additional_integrations_is_list(self, backend): - """Test that additional_integrations returns a list.""" - integrations = backend.additional_integrations - assert isinstance(integrations, list) - assert all(isinstance(i, str) for i in integrations) - # Configuration tests def test_config_type_returns_class(self, backend): diff --git a/sunbeam-python/tests/unit/sunbeam/storage/conftest.py b/sunbeam-python/tests/unit/sunbeam/storage/conftest.py index edc15c579..9ecad71a1 100644 --- a/sunbeam-python/tests/unit/sunbeam/storage/conftest.py +++ b/sunbeam-python/tests/unit/sunbeam/storage/conftest.py @@ -42,10 +42,6 @@ def charm_name(self) -> str: def charm_channel(self) -> str: return "latest/stable" - @property - def backend_endpoint(self) -> str: - return "cinder-volume" - def config_type(self) -> type[MockStorageConfig]: return MockStorageConfig diff --git a/sunbeam-python/tests/unit/sunbeam/storage/test_base.py b/sunbeam-python/tests/unit/sunbeam/storage/test_base.py index 8b470d908..7a278ce27 100644 --- a/sunbeam-python/tests/unit/sunbeam/storage/test_base.py +++ b/sunbeam-python/tests/unit/sunbeam/storage/test_base.py @@ -140,21 +140,11 @@ def test_charm_base_is_set(self, backend): assert backend.charm_base assert isinstance(backend.charm_base, str) - def test_backend_endpoint_is_set(self, backend): - """Test that backend_endpoint is set.""" - assert backend.backend_endpoint - assert isinstance(backend.backend_endpoint, str) - def test_principal_application_is_set(self, backend): """Test that principal_application is set.""" assert backend.principal_application assert isinstance(backend.principal_application, str) - def test_units_is_positive(self, backend): - """Test that units is a positive integer.""" - assert backend.units > 0 - assert isinstance(backend.units, int) - def test_tfplan_properties(self, backend): """Test Terraform plan properties.""" assert backend.tfplan @@ -515,8 +505,3 @@ def test_manifest_property_failure(self, backend, mock_click_context): with patch("click.get_current_context", return_value=mock_click_context): with pytest.raises(ValueError, match="Failed to load manifest"): _ = backend.manifest - - def test_additional_integrations_default(self, backend): - """Test that additional_integrations returns a list.""" - integrations = backend.additional_integrations - assert isinstance(integrations, list)