diff --git a/ewccli/backends/openstack/backend_ostack.py b/ewccli/backends/openstack/backend_ostack.py index a63be80..395e0bc 100644 --- a/ewccli/backends/openstack/backend_ostack.py +++ b/ewccli/backends/openstack/backend_ostack.py @@ -33,8 +33,10 @@ # failures The number of server creation failures creating the server ServerResult = namedtuple("ServerResult", "success changed failures") KeyPairResult = namedtuple("KeyPairResult", "success changed") +ExtraVolumesResult = namedtuple("ExtraVolumesResult", "success changed") ExternalIPResult = namedtuple("ExternalIPResult", "success changed") NetworkResult = namedtuple("NetworkResult", "success changed") + _MAX_CHARACTERS_SERVER_NAME_OPENSTACK = 63 @@ -389,6 +391,228 @@ def create_server( ) + def create_volumes( + self, + conn: openstack.connection.Connection, + base_name: str, + volume_sizes: tuple[int, ...], + volume_type: str | None = None, + attempts: int = 1, + retry_delay_s: int = 30, + wait_time_s: int = 600, + dry_run: bool = False, + metadata=None, + ) -> tuple[ExtraVolumesResult, list[openstack.block_storage.v3.volume.Volume], str]: + """ + Create multiple Cinder volumes with retry and wait logic. + + :param conn: OpenStack connection + :param base_name: Base name for volumes (e.g. server name) + :param volume_sizes: Tuple of sizes in GB + :param volume_type: Optional Cinder volume type + :param attempts: Retry attempts + :param retry_delay_s: Delay between attempts + :param wait_time_s: Max wait time for volume creation + :param dry_run: Do not create anything + :return: (ExtraVolumesResult, list of created volumes, message) + """ + if dry_run: + _LOGGER.info(f"[Dry Run] Would create extra volumes with sizes: {volume_sizes}") + return ( + ExtraVolumesResult(True, False), + [], + f"[Dry Run] Would create extra volumes with sizes: {volume_sizes}", + ) + + attempt = 1 + error_message = "" + final_metadata = { + "ewccli": "true", + "server_name": base_name, + **metadata, + } + created_volumes = [] + + while attempt <= attempts: + _LOGGER.info(f"Creating volumes (attempt {attempt}/{attempts})") + created_volumes = [] + + try: + # Create all volumes + for idx, size in enumerate(volume_sizes): + suffix = int(time.time()) + vol_name = f"{base_name}-vol-{idx+1}-{suffix}" + _LOGGER.info(f"Creating volume {vol_name} ({size} GB)") + + vol = conn.block_storage.create_volume( + size=size, + name=vol_name, + volume_type=volume_type, + metadata=final_metadata + ) + created_volumes.append(vol) + + # Wait for all volumes to become available + ready_volumes = [] + for vol in created_volumes: + _LOGGER.info(f"Waiting for volume {vol.name} to become available") + vol = conn.block_storage.wait_for_status( + vol, + status="available", + failures=["error"], + wait=wait_time_s, + ) + ready_volumes.append(vol) + + return ( + ExtraVolumesResult(True, True), + ready_volumes, + "Successfully created volumes.", + ) + + except Exception as ex: + error_message = f"Volume creation failed: {ex}" + _LOGGER.error(error_message) + + # Cleanup failed volumes + for vol in created_volumes: + try: + _LOGGER.warning(f"Deleting failed volume {vol.name}") + conn.block_storage.delete_volume(vol, ignore_missing=True) + except Exception as cleanup_ex: + _LOGGER.error(f"Failed to delete volume {vol.name}: {cleanup_ex}") + + if attempt < attempts: + _LOGGER.info(f"Retrying in {retry_delay_s} seconds…") + time.sleep(retry_delay_s) + attempt += 1 + + + return ( + ExtraVolumesResult(False, False), + [], + error_message, + ) + + + def list_volumes( + self, + conn, + metadata: dict[str, str] | None = None, + name: str | None = None, + status: str | None = None, + ): + """ + List Cinder volumes filtered by metadata (default: ewccli=true). + + :param conn: OpenStack connection + :param metadata: Extra metadata filters + :param name: Optional name filter + :param status: Optional status filter + :return: List of matching volumes + """ + + # Default metadata filter + base_metadata = {"ewccli": "true"} + + # Merge user-provided metadata + if metadata: + base_metadata.update(metadata) + + filters = { + "metadata": base_metadata, + } + + if name: + filters["name"] = name + + if status: + filters["status"] = status + + # Query volumes + return list(conn.block_storage.volumes(details=True, **filters)) + + + def delete_volumes( + self, + conn: openstack.connection.Connection, + base_name: str | None = None, + metadata: dict[str, str] | None = None, + attempts: int = 1, + retry_delay_s: int = 30, + wait_time_s: int = 600, + dry_run: bool = False, + ) -> tuple[ExtraVolumesResult, list[openstack.block_storage.v3.volume.Volume], str]: + """ + Delete Cinder volumes filtered by metadata (default: ewccli=true). + + :param conn: OpenStack connection + :param base_name: Optional base name (e.g. server name) + :param metadata: Extra metadata filters + :param attempts: Retry attempts + :param retry_delay_s: Delay between attempts + :param wait_time_s: Max wait time for deletion + :param dry_run: Do not delete anything + :return: (ExtraVolumesResult, list of deleted volumes, message) + """ + # Default metadata filter + base_metadata = {"ewccli": "true"} + + # Add server-name if provided + if base_name: + base_metadata["server_name"] = base_name + + # Merge user metadata + if metadata: + base_metadata.update(metadata) + + # Find volumes + volumes = list(conn.block_storage.volumes(details=True, metadata=base_metadata)) + + if not volumes: + return ExtraVolumesResult(True, False), [], "No volumes matched the filters." + + if dry_run: + _LOGGER.info(f"[Dry Run] Would delete {len(volumes)} volumes: {[v.name for v in volumes]}") + return ( + ExtraVolumesResult(True, False), + [], + f"[Dry Run] Would delete {len(volumes)} volumes", + ) + + deleted = [] + errors = [] + + for vol in volumes: + for attempt in range(1, attempts + 1): + try: + conn.block_storage.delete_volume(vol, ignore_missing=True) + conn.block_storage.wait_for_delete(vol, wait=wait_time_s) + deleted.append(vol) + break + except Exception as exc: + _LOGGER.warning( + f"Failed to delete volume {vol.name} (attempt {attempt}/{attempts}): {exc}" + ) + if attempt < attempts: + time.sleep(retry_delay_s) + else: + errors.append((vol, str(exc))) + + success = len(errors) == 0 + + msg = ( + f"Deleted {len(deleted)} volumes" + if success + else f"Deleted {len(deleted)} volumes, {len(errors)} failed" + ) + + return ExtraVolumesResult( + True if success else False, + True if success else False + ), deleted, msg + + def find_latest_image( self, conn: openstack.connection.Connection, @@ -822,7 +1046,6 @@ def remove_network( _LOGGER.warning(f"{network_name} not found for server {server.name}") return NetworkResult(True, False) - def ssh_key_matches_openstack( self, public_key_path: str, @@ -852,7 +1075,6 @@ def ssh_key_matches_openstack( return local_key == openstack_key - def create_keypair( self, conn: openstack.connection.Connection, @@ -867,7 +1089,11 @@ def create_keypair( :param public_key_path: Path to the public_key_path """ if dry_run: - return KeyPairResult(True, False) + _LOGGER.debug(f"[Dry Run] Would create keypair '{keypair_name}'.") + return ( + KeyPairResult(True, False), + f"[Dry Run] Would create keypair '{keypair_name}'.", + ) # Step: Upload the public key with open(public_key_path, "r") as key_file: diff --git a/ewccli/commands/commons.py b/ewccli/commands/commons.py index 64f2231..3aac260 100644 --- a/ewccli/commands/commons.py +++ b/ewccli/commands/commons.py @@ -257,6 +257,12 @@ def openstack_optional_options(func): show_default=True, help="Add External IP to the machine.", )(func) + func = click.option( + "--extra-volume", + type=int, + multiple=True, + help="Attach an extra volume of the given size in GB. Can be used multiple times.", + )(func) return func diff --git a/ewccli/commands/hub/hub_command.py b/ewccli/commands/hub/hub_command.py index ec20f78..ac6fc0f 100644 --- a/ewccli/commands/hub/hub_command.py +++ b/ewccli/commands/hub/hub_command.py @@ -353,6 +353,7 @@ def deploy_cmd( # noqa: CFQ002, CFQ001, CCR001, C901 external_ip: bool = False, networks: Optional[tuple] = None, security_groups: Optional[tuple] = None, + extra_volume: Optional[tuple] = None, ssh_private_encoded: Optional[str] = None, ssh_public_encoded: Optional[str] = None, ): diff --git a/ewccli/commands/infra_command.py b/ewccli/commands/infra_command.py index d3b983f..456c0b3 100644 --- a/ewccli/commands/infra_command.py +++ b/ewccli/commands/infra_command.py @@ -127,6 +127,7 @@ def create_cmd( external_ip: bool = False, networks: Optional[tuple] = None, security_groups: Optional[tuple] = None, + extra_volume: Optional[tuple] = None, ssh_private_encoded: Optional[str] = None, ssh_public_encoded: Optional[str] = None, ): @@ -176,6 +177,7 @@ def create_cmd( "external_ip": external_ip, "networks": networks, "security_groups": security_groups, + "extra_volume": extra_volume } os_status_code, os_message, outputs = deploy_server( diff --git a/ewccli/tests/ewccli_backends_openstack_test.py b/ewccli/tests/ewccli_backends_openstack_test.py index 7864bd4..6c864c8 100644 --- a/ewccli/tests/ewccli_backends_openstack_test.py +++ b/ewccli/tests/ewccli_backends_openstack_test.py @@ -7,10 +7,12 @@ import pytest -from pathlib import Path from types import SimpleNamespace +from unittest.mock import MagicMock +from unittest.mock import ANY from ewccli.backends.openstack.backend_ostack import OpenstackBackend +from ewccli.backends.openstack.backend_ostack import ExtraVolumesResult @pytest.fixture @@ -19,6 +21,149 @@ def backend(): return OpenstackBackend.__new__(OpenstackBackend) +@pytest.fixture +def fake_conn(): + """Mocked OpenStack connection.""" + conn = SimpleNamespace() + conn.block_storage = MagicMock() + return conn + + +def make_volume(name="vol1", status="available", metadata=None): + return SimpleNamespace( + name=name, + status=status, + metadata=metadata or {}, + ) + +def test_list_volumes_default_metadata(backend, fake_conn): + vol1 = make_volume("v1", metadata={"ewccli": "true"}) + vol2 = make_volume("v2", metadata={"ewccli": "false"}) + + fake_conn.block_storage.volumes.return_value = [vol1] + + result = backend.list_volumes(fake_conn) + + fake_conn.block_storage.volumes.assert_called_once_with( + details=True, + metadata={"ewccli": "true"}, + ) + + assert result == [vol1] + + +def test_create_volumes_dry_run(backend, fake_conn): + res, vols, msg = backend.create_volumes( + conn=fake_conn, + base_name="server1", + volume_sizes=(10, 20), + dry_run=True, + ) + + assert isinstance(res, ExtraVolumesResult) + assert res.success is True + assert res.changed is False + assert vols == [] + assert "Dry Run" in msg + + + +def test_create_volumes_success(backend, fake_conn): + # Mock create_volume → returns fake volume objects + created = [ + make_volume("vol1", status="creating"), + make_volume("vol2", status="creating"), + ] + fake_conn.block_storage.create_volume.side_effect = created + + # Mock wait_for_status → returns ready volumes + ready = [ + make_volume("vol1", status="available"), + make_volume("vol2", status="available"), + ] + fake_conn.block_storage.wait_for_status.side_effect = ready + + res, vols, msg = backend.create_volumes( + conn=fake_conn, + base_name="server1", + volume_sizes=(10, 20), + metadata={"custom": "yes"}, + ) + + assert res.success is True + assert res.changed is True + assert len(vols) == 2 + assert vols[0].status == "available" + assert "Successfully created" in msg + + # Metadata merged correctly + fake_conn.block_storage.create_volume.assert_any_call( + size=10, + name=ANY, + volume_type=None, + metadata={ + "ewccli": "true", + "server_name": "server1", + "custom": "yes", + }, + ) + + +def test_delete_volumes_dry_run(backend, fake_conn): + vol = make_volume("vol1", metadata={"ewccli": "true"}) + fake_conn.block_storage.volumes.return_value = [vol] + + res, deleted, msg = backend.delete_volumes( + conn=fake_conn, + dry_run=True, + ) + + assert res.success is True + assert res.changed is False + assert deleted == [] + assert "Dry Run" in msg + + +def test_delete_volumes_success(backend, fake_conn): + vol = make_volume("vol1", metadata={"ewccli": "true"}) + fake_conn.block_storage.volumes.return_value = [vol] + + res, deleted, msg = backend.delete_volumes( + conn=fake_conn, + base_name="server1", + ) + + fake_conn.block_storage.delete_volume.assert_called_once_with(vol, ignore_missing=True) + fake_conn.block_storage.wait_for_delete.assert_called_once() + + assert res.success is True + assert res.changed is True + assert deleted == [vol] + assert "Deleted 1 volumes" in msg + + +def test_delete_volumes_failure(backend, fake_conn): + vol = make_volume("vol1", metadata={"ewccli": "true"}) + fake_conn.block_storage.volumes.return_value = [vol] + + # First attempt fails, second attempt fails → error recorded + fake_conn.block_storage.delete_volume.side_effect = Exception("boom") + + res, deleted, msg = backend.delete_volumes( + conn=fake_conn, + attempts=2, + retry_delay_s=0, + ) + + assert res.success is False + assert res.changed is False + assert deleted == [] + assert "failed" in msg + + +############################################################# +############################################################# + def test_ssh_key_matches_openstack_true(tmp_path, backend): """ Test that matching keys return True.