Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 229 additions & 3 deletions ewccli/backends/openstack/backend_ostack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -852,7 +1075,6 @@ def ssh_key_matches_openstack(

return local_key == openstack_key


def create_keypair(
self,
conn: openstack.connection.Connection,
Expand All @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions ewccli/commands/commons.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions ewccli/commands/hub/hub_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
):
Expand Down
2 changes: 2 additions & 0 deletions ewccli/commands/infra_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
):
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading