diff --git a/.github/scripts/test-deployment-ansible.sh b/.github/scripts/test-deployment-ansible.sh index f123200..3b920e0 100755 --- a/.github/scripts/test-deployment-ansible.sh +++ b/.github/scripts/test-deployment-ansible.sh @@ -11,7 +11,7 @@ export OS_AUTH_TYPE=v3applicationcredential export OS_INTERFACE=public export OS_IDENTITY_API_VERSION=3 -# --- Step 1 --- +# --- Step 1 --- echo "Clone catalog repository (ref: '${CATALOG_REF}')" PATH_TO_CATALOG_REPO="${GITHUB_WORKSPACE}/ewc-community-hub" git clone \ @@ -20,7 +20,7 @@ git clone \ "https://github.com/ewcloud/ewc-community-hub.git" \ "${PATH_TO_CATALOG_REPO}" -# --- Step 2 --- +# --- Step 2 --- echo "Extract metadata of '${ITEM_NAME}' from catalog" PATH_TO_CATALOG="${PATH_TO_CATALOG_REPO}/items.yaml" item_metadata=$(yq '.spec.items."'"$ITEM_NAME"'" | del(.description)' $PATH_TO_CATALOG) # Drop the description attribute(for succinct step summary) @@ -51,7 +51,7 @@ if [[ "${INPUT_SPEC_JSON}" != "{}" ]]; then to_entries[] | "--item-inputs" , (.key + "=" + - if .value == null then + if .value == null then (null | tojson) elif (.value | type) == "array" or (.value | type) == "object" then (.value | tojson) @@ -87,7 +87,7 @@ chmod 600 "$ANSIBLE_SSH_PUBLIC_KEY_FILE" export EWC_CLI_SSH_PUBLIC_KEY_PATH=$ANSIBLE_SSH_PUBLIC_KEY_FILE export EWC_CLI_SSH_PRIVATE_KEY_PATH=$ANSIBLE_SSH_PRIVATE_KEY_FILE -# --- Step 7 --- +# --- Step 7 --- echo "Login" EWCCLI_LOGIN_EXIT_CODE=0 @@ -97,10 +97,10 @@ set +e EWCCLI_LOGIN_EXIT_CODE=$? set -e -# --- Step 8 --- +# --- Step 8 --- echo "Deploy (including VM provisioning)" -if [ -z "${EXTRA_VARS}" ]; then +if [ -z "${EXTRA_VARS}" ]; then EWCCLI_DEPLOY_CMD=(ewc hub --path-to-catalog "${PATH_TO_CATALOG}" deploy "${ITEM_NAME}" --server-name "github-vm-${GITHUB_RUN_ID}" --external-ip) else EWCCLI_DEPLOY_CMD=(ewc hub --path-to-catalog "${PATH_TO_CATALOG}" deploy "${ITEM_NAME}" --server-name "github-vm-${GITHUB_RUN_ID}" --external-ip "${EXTRA_VARS[@]}") @@ -112,7 +112,7 @@ set +e EWCCLI_DEPLOY_EXIT_CODE=$? set -e -# --- Step 9 --- +# --- Step 9 --- echo "Cleanup" EWCCLI_CLEANUP_EXIT_CODE=0 @@ -197,9 +197,9 @@ cp $GITHUB_STEP_SUMMARY "$ARTIFACTS_DIR/summary.md" # NOTE: As of 18.02.2026, this is needed for cleaning up any created Floating IPs, since the EWCCLI does not remove them and is unclear if it reuses existing consistently echo "Unlocking the Floating IP (best-effort approach)" floating_ip=$(openstack server show "github-vm-${GITHUB_RUN_ID}" -f json | jq '.addresses[].[] | select( (startswith("192.") or startswith("10.")) | not )' | tr -d '"' ) || true -openstack floating ip delete $floating_ip || true +openstack floating ip delete $floating_ip || true -# --- Step 13 --- +# --- Step 13 --- echo "Re-rasing test errors (if any)" if [ "$EWCCLI_STATUS" = "failing" ]; then echo "::error::One or more failures caught during testing. See the summary or logs for details" diff --git a/.github/workflows/test-deployment-ansible-ecmwf.yml b/.github/workflows/test-deployment-ansible-ecmwf.yml index 3829b70..462ff72 100644 --- a/.github/workflows/test-deployment-ansible-ecmwf.yml +++ b/.github/workflows/test-deployment-ansible-ecmwf.yml @@ -34,7 +34,7 @@ env: ANSIBLE_SSH_PUBLIC_KEY: '${{ secrets.ECMWF_ANSIBLE_SSH_PUBLIC_KEY }}' ITEM_NAME: ${{ inputs.itemName }} CATALOG_REF: ${{ inputs.catalogRef }} - INPUT_SPEC_JSON: '${{ inputs.inputSpecJson }}' + INPUT_SPEC_JSON: '${{ inputs.inputSpecJson }}' jobs: deploy-test: diff --git a/.github/workflows/test-deployment-ansible-eumetsat.yml b/.github/workflows/test-deployment-ansible-eumetsat.yml index 421384e..538153e 100644 --- a/.github/workflows/test-deployment-ansible-eumetsat.yml +++ b/.github/workflows/test-deployment-ansible-eumetsat.yml @@ -34,7 +34,7 @@ env: ANSIBLE_SSH_PUBLIC_KEY: '${{ secrets.EUMETSAT_ANSIBLE_SSH_PUBLIC_KEY }}' ITEM_NAME: ${{ inputs.itemName }} CATALOG_REF: ${{ inputs.catalogRef }} - INPUT_SPEC_JSON: '${{ inputs.inputSpecJson }}' + INPUT_SPEC_JSON: '${{ inputs.inputSpecJson }}' jobs: deploy-test: diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index 47b24ce..5be5fc4 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -22,7 +22,7 @@ jobs: - name: Install Python dependencies run: pip install -e .[test] - + - name: Run pre-commit run: pre-commit run --all-files @@ -43,6 +43,6 @@ jobs: - name: Install Python dependencies run: pip install -e .[test] - + - name: Run pytest run: pytest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 15606e8..2647206 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,6 +4,8 @@ # See the LICENSE file for more details --- +exclude: ^ewccli/tests/ + repos: - repo: https://github.com/Lucas-C/pre-commit-hooks rev: v1.5.5 diff --git a/README.md b/README.md index d80e1c7..7c39bd5 100644 --- a/README.md +++ b/README.md @@ -196,8 +196,8 @@ Info required for a profile: [my-profile] federee = EUMETSAT or ECMWF tenant_name = eumetsat-ewc-communityhub -application_credential_id = -application_credential_secret = +application_credential_id = +application_credential_secret = ssh_public_key_path = ssh_private_key_path = ``` @@ -225,7 +225,7 @@ where ITEM is taken from `ewc hub list` command under Item column. If you would like to test the deployment of: * **an Item with private source code (local or remote)** - + OR * **a new Item, not yet published in the EWC Community Hub** @@ -283,14 +283,14 @@ spec: published: true ``` -where +where - `sources` can be (only the first element in the list is considered): - Public repo URL (e.g. https://github.com/your-repo.git) if your repository is public already - Absolute path to a directory with the Item (e.g. `/home/murdaca/custom-items/new-item`). The path needs to point to a directory that needs to exists an not be empty. (WARNING: No local path are accepted!) - `pathToMainFile` is the relattive path to your directory or repository - `pathToRequirementsFile` is the relattive path to your directory or repository -- `publicIP` is a flag used to enable deployment of +- `publicIP` is a flag used to enable deployment of - `ewccli.inputs` is the list of inputs you want the user to be able to provide, they can be mandatory or optional, respecively with default key not set or set. ### Running a test diff --git a/ewccli/backends/kubernetes/utils.py b/ewccli/backends/kubernetes/utils.py index 2c8c801..b1cff28 100644 --- a/ewccli/backends/kubernetes/utils.py +++ b/ewccli/backends/kubernetes/utils.py @@ -8,8 +8,10 @@ """Helpers methods for Kubernetes backend.""" +from typing import Any, List, Dict -def get_status_from_conditions(conditions: list) -> str: + +def get_status_from_conditions(conditions: List[Dict[str, Any]]) -> Any: """ Extract a human-readable status from conditions list. @@ -27,7 +29,7 @@ def get_status_from_conditions(conditions: list) -> str: return f"{first.get('type')}: {first.get('status')}" -def get_reason_from_conditions(conditions: list) -> str: +def get_reason_from_conditions(conditions: List[Dict[str, Any]]) -> Any: """ Extract the reason from the 'Ready' condition if available, otherwise fallback to the first condition's reason. diff --git a/ewccli/backends/openstack/backend_ostack.py b/ewccli/backends/openstack/backend_ostack.py index a63be80..8ebcc06 100644 --- a/ewccli/backends/openstack/backend_ostack.py +++ b/ewccli/backends/openstack/backend_ostack.py @@ -11,7 +11,7 @@ import time import sys import os -from typing import Tuple, Optional, Any +from typing import Tuple, Optional, Any, Dict from collections import namedtuple from pathlib import Path @@ -65,9 +65,7 @@ def __init__( ) self.auth_url = auth_url else: - config = ( - OpenStackConfig() - ) # ~/.config/openstack/clouds.yaml, to change use OS_CLIENT_CONFIG_FILE + config = OpenStackConfig() # ~/.config/openstack/clouds.yaml, to change use OS_CLIENT_CONFIG_FILE # Get the default cloud if no name is specified cloud = ( config.get_one() @@ -133,14 +131,14 @@ def create_server( server_name: str, image_name: str, flavour_name: str, - networks: tuple, + networks: Optional[tuple], keypair_name: str, - sec_groups: tuple, + sec_groups: Optional[tuple], attempts: int = 1, retry_delay_s: int = 30, wait_time_s: int = 600, dry_run: bool = False, - ) -> Tuple[ServerResult, Optional[str], dict[Any, Any]]: + ) -> Tuple[ServerResult, Optional[str], Dict[str, Any]]: """Create an OpenStack server. Automatically deletes and retries the creation process until a server is available. @@ -388,16 +386,12 @@ def create_server( new_server, ) - - def find_latest_image( - self, - conn: openstack.connection.Connection, - prefix: str - ): + def find_latest_image(self, conn: openstack.connection.Connection, prefix: str): """ Select the latest image for CPU or GPU families with special rules. """ import re + TIMESTAMP_RE = r"\d{14}" def is_cpu_image(prefix: str, name: str): @@ -420,11 +414,13 @@ def is_cpu_image(prefix: str, name: str): def is_gpu_rocky(name: str): # Prefix: Rocky-9-GPU # Match: Rocky-9.-GPU- - return bool(re.match( - rf"^Rocky-9\.\d+-GPU-{TIMESTAMP_RE}$", - name, - re.IGNORECASE, - )) + return bool( + re.match( + rf"^Rocky-9\.\d+-GPU-{TIMESTAMP_RE}$", + name, + re.IGNORECASE, + ) + ) def is_gpu_ubuntu(name: str): # Prefix: Ubuntu 22.04 NVIDIA_AI @@ -451,7 +447,11 @@ def image_matches(name: str, prefix: str): return False # Filter matching images - matches = [img for img in conn.compute.images() if image_matches(name=img.name, prefix=prefix)] + matches = [ + img + for img in conn.compute.images() + if image_matches(name=img.name, prefix=prefix) + ] if not matches: return None @@ -460,7 +460,6 @@ def image_matches(name: str, prefix: str): matches.sort(key=lambda img: img.created_at, reverse=True) return matches[0] - def check_server_inputs( self, conn: openstack.connection.Connection, @@ -474,7 +473,9 @@ def check_server_inputs( image = conn.compute.find_image(image_name) if not image: - total_images = ewc_hub_config.EWC_CLI_CPU_IMAGES + [ewc_hub_config.EWC_CLI_GPU_IMAGES_SITE_MAP[federee]] + total_images = ewc_hub_config.EWC_CLI_CPU_IMAGES + [ + ewc_hub_config.EWC_CLI_GPU_IMAGES_SITE_MAP[federee] + ] error_message = ( f"❌ Unsupported OS image for the EWC CLI: {image_name}\n\n" f"đŸ–Ĩī¸ EWC Supported images (short names): [bold green]{', '.join(total_images)}[/bold green]\n" @@ -528,7 +529,6 @@ def check_server_inputs( return True, "" - def list_servers( self, conn: openstack.connection.Connection, @@ -550,7 +550,6 @@ def list_servers( image_map = {image.id: image.name for image in conn.compute.images()} for server in conn.compute.servers(): - if ( not ( server.metadata.get("deployed") @@ -822,12 +821,7 @@ 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, - keypair: dict - ) -> bool: + def ssh_key_matches_openstack(self, public_key_path: str, keypair: dict) -> bool: """ Check whether the local SSH public key matches the OpenStack keypair. @@ -852,7 +846,6 @@ def ssh_key_matches_openstack( return local_key == openstack_key - def create_keypair( self, conn: openstack.connection.Connection, @@ -877,11 +870,8 @@ def create_keypair( existing_key = conn.compute.find_keypair(keypair_name) if existing_key: - match = OpenstackBackend.ssh_key_matches_openstack( - conn, - keypair=existing_key, - public_key_path=public_key_path + conn, keypair=existing_key, public_key_path=public_key_path ) if not match: diff --git a/ewccli/commands/commons.py b/ewccli/commands/commons.py index 05357c7..4e5045e 100644 --- a/ewccli/commands/commons.py +++ b/ewccli/commands/commons.py @@ -7,6 +7,8 @@ """Common methods for all commands.""" +from __future__ import annotations + import re import sys import os @@ -14,7 +16,8 @@ import socket import time from pathlib import Path -from typing import Optional +from typing import Any, Dict, List, Tuple, Optional +from typing import Callable from datetime import datetime, timezone import yaml @@ -26,7 +29,7 @@ from rich.align import Align from ewccli.backends.kubernetes.utils import get_reason_from_conditions -from ewccli.enums import HubItemOherAnnotation, HubItemCLIKeys +from ewccli.enums import HubItemOherAnnotation from ewccli.configuration import config as ewc_hub_config from ewccli.utils import download_items from ewccli.logger import get_logger @@ -41,31 +44,38 @@ class CommonBackendContext: """CommonBackendContext.""" - def __init__(self): - self.cli_profile = None + def __init__(self) -> None: + self.cli_profile: Any = None + class CommonContext: """CommonContext.""" - def __init__(self): - self.cli_profile = None - self.items = load_hub_items() + def __init__(self) -> None: + self.cli_profile: Any = None + self.items: dict[str, Any] = load_hub_items() -def validate_config_name(ctx, param, value): +def validate_config_name( + ctx: click.Context, + param: click.Parameter, + value: Optional[str], +) -> Optional[str]: """Validate config name.""" - if not value: - return value + if value is None: + return None pattern = r"^[a-zA-Z0-9]+-[a-zA-Z0-9]+-[a-zA-Z0-9]+-[a-zA-Z0-9]+$" if not re.match(pattern, value): raise click.BadParameter( - "Config name must be exactly 4 alphanumeric parts separated by dashes (e.g. tenant-federee-east-zone)." + "Config name must be exactly 4 alphanumeric parts separated by dashes " + "(e.g. tenant-federee-east-zone)." ) + return value -def login_options(func): +def login_options(func: Callable[..., Any]) -> Callable[..., Any]: """Login option for the CLI commands.""" func = click.option( "--profile", @@ -76,7 +86,7 @@ def login_options(func): return func -def default_keypair_name(): +def default_keypair_name() -> str: """Retrieve default keypair name from username.""" # Safest way to get Linux username username = getpass.getuser() @@ -87,36 +97,37 @@ def default_keypair_name(): KEYPAIT_DEFAULT = default_keypair_name() -def default_username(): +def default_username() -> str: """Retrieve username runnnig the CLI.""" # Safest way to get Linux username username = getpass.getuser() return f"{username}" -def load_hub_items(path_to_catalog: str = ewc_hub_config.EWC_CLI_HUB_ITEMS_PATH) -> dict: +def load_hub_items( + path_to_catalog: Path = ewc_hub_config.EWC_CLI_HUB_ITEMS_PATH, +) -> Dict[str, Any]: """Load EWC Hub Items from file.""" download_items() - with open(path_to_catalog, "r") as file: + with open(path_to_catalog, "r", encoding="utf-8") as file: items_file = yaml.safe_load(file) - if not items_file: - _LOGGER.error("items.yaml is empty.") - sys.exit(1) - - items_spec = items_file.get("spec") + if not isinstance(items_file, dict): + _LOGGER.error("items.yaml is malformed or empty.") + sys.exit(1) - if not items_spec: - _LOGGER.error("spec key is missing from items.yaml.") - sys.exit(1) + items_spec = items_file.get("spec") + if not isinstance(items_spec, dict): + _LOGGER.error("spec key is missing or invalid in items.yaml.") + sys.exit(1) - items = items_spec.get("items") + items = items_spec.get("items") + if not isinstance(items, dict): + _LOGGER.error("items key is missing or invalid under spec in items.yaml.") + sys.exit(1) - if not items: - _LOGGER.error("items key is missing from spec key in items.yaml.") - sys.exit(1) - - return items + # At this point, mypy knows `items` is a dict[str, Any] + return items def split_config_name(config_name: str) -> tuple[str, str]: @@ -137,7 +148,7 @@ def split_config_name(config_name: str) -> tuple[str, str]: return federee, tenant_name -def openstack_options(func): +def openstack_options(func: Callable[..., Any]) -> Callable[..., Any]: """Openstack options for the CLI commands.""" func = click.option( "--auth-url", @@ -165,7 +176,11 @@ def openstack_options(func): return func -def _split_env_var(ctx, param, value) -> tuple: +def _split_env_var( + ctx: click.Context, + param: click.Parameter, + value: Optional[str | Tuple[str, ...]], +) -> Tuple[str, ...]: """Split env var or CLI input into a tuple of unique parameters.""" if value is None: return () @@ -173,20 +188,21 @@ def _split_env_var(ctx, param, value) -> tuple: if isinstance(value, tuple): raw_parameters = value else: - raw_parameters = value.split(",") + raw_parameters = tuple(value.split(",")) + + seen: set[str] = set() + unique_parameters: list[str] = [] - seen = set() - unique_parameters = [] for parameter in raw_parameters: - parameter = parameter.strip() - if parameter and parameter not in seen: - seen.add(parameter) - unique_parameters.append(parameter) + p = parameter.strip() + if p and p not in seen: + seen.add(p) + unique_parameters.append(p) return tuple(unique_parameters) -def openstack_optional_options(func): +def openstack_optional_options(func: Callable[..., Any]) -> Callable[..., Any]: """Openstack optional options for the CLI commands.""" func = click.option( "--networks", @@ -261,7 +277,7 @@ def openstack_optional_options(func): return func -def ssh_options_encoded(func): +def ssh_options_encoded(func: Callable[..., Any]) -> Callable[..., Any]: """SSH options encoded for the CLI commands.""" func = click.option( "--ssh-private-encoded", @@ -281,10 +297,14 @@ def ssh_options_encoded(func): return func -def validate_path(ctx, param, value): - """Validate path.""" +def validate_path( + ctx: click.Context, + param: click.Parameter, + value: Optional[str], +) -> Optional[Path]: + """Validate and normalize a filesystem path for CLI parameters.""" if not value: - return + return None try: # Expand ~ and resolve to absolute path path = Path(value).expanduser().resolve(strict=False) @@ -309,7 +329,7 @@ def validate_path(ctx, param, value): raise click.BadParameter(f"Invalid path '{value}': {e}") -def ssh_options(func): +def ssh_options(func: Callable[..., Any]) -> Callable[..., Any]: """SSH options for the CLI commands.""" func = click.option( "--ssh-public-key-path", @@ -331,7 +351,7 @@ def ssh_options(func): return func -def list_items_table(hub_items: dict): +def list_items_table(hub_items: Dict[str, Any]) -> None: """List items in table.""" table = Table( show_header=True, @@ -374,87 +394,146 @@ def list_items_table(hub_items: dict): ) -def show_item_table(hub_item: dict, default_admin_variables_map: Optional[dict] = None): - """Show item metadata table.""" - table = Table( - show_header=True, - show_lines=True, - header_style="bold green", - title=f"{hub_item.get('name')} EWC Item Details", - box=box.MINIMAL_DOUBLE_HEAD, - ) - table.add_column("Metadata", no_wrap=False, min_width=15) - table.add_column("Data", no_wrap=False, min_width=15) - +def _add_basic_metadata(table: Table, hub_item: Dict[str, Any]) -> None: table.add_row("name", hub_item.get("name")) table.add_row("version", hub_item.get("version")) table.add_row("summary", hub_item.get("summary")) - for maintainer in hub_item.get("maintainers", {}): - if maintainer.get("url"): + +def _add_maintainers(table: Table, hub_item: Dict[str, Any]) -> None: + for maintainer in hub_item.get("maintainers", []): + name = maintainer.get("name") + url = maintainer.get("url") + email = maintainer.get("email") + + if url: table.add_row( "maintainer", - f"[bold]{maintainer.get('name')}:[/bold] [link={maintainer.get('url')}]{maintainer.get('url')}", + f"[bold]{name}:[/bold] [link={url}]{url}", ) else: table.add_row( - f"maintainer: {maintainer.get('name')}", - f"[link={maintainer.get('email')}]{maintainer.get('email')}", + f"maintainer: {name}", + f"[link={email}]{email}", ) - table.add_row("home", f"[link={hub_item.get('home')}]{hub_item.get('home')}") - table.add_row( - "license", f"[link={hub_item.get('license')}]{hub_item.get('license')}" - ) - for annotation, a_v in hub_item.get("annotations", {}).items(): - table.add_row(annotation, a_v) - md_description = Markdown(hub_item.get("description")) - table.add_row("description", Align(md_description, align="left", width=80)) +def _add_annotations(table: Table, hub_item: Dict[str, Any]) -> None: + for key, value in hub_item.get("annotations", {}).items(): + table.add_row(key, value) + + +def _add_description(table: Table, hub_item: Dict[str, Any]) -> None: + md = Markdown(hub_item.get("description")) + table.add_row("description", Align(md, align="left", width=80)) + - deploy_command_example = f"ewc hub deploy {hub_item.get('name')}" +def _extract_default_admin_vars(default_map: Optional[Dict[str, Any]]) -> List[str]: + if not default_map: + return [] + return list(default_map) - if not default_admin_variables_map: - default_admin_variables_map = {} - default_admin_variables = [dav for dav in default_admin_variables_map] +def _input_is_mandatory(mi: Dict[str, Any], default_admin_vars: List[str]) -> bool: + name = mi.get("name") + return "default" not in mi and name not in default_admin_vars + + +def _format_default(mi: Dict[str, Any]) -> str: + if "default" in mi: + return f" (default: {mi['default']})" + return "" + + +def _format_input_line(mi: Dict[str, Any], mandatory: bool, default_str: str) -> str: + name = mi.get("name") + type_ = mi.get("type") + desc = mi.get("description") + tag = "(mandatory)" if mandatory else "(optional)" + return f"{tag} {name}: ({type_}){default_str}: {desc}\n" + + +def _maybe_add_to_deploy_cmd( + deploy_cmd: str, + mi: Dict[str, Any], + mandatory: bool, +) -> str: + if mandatory: + name = mi.get("name") + return f"{deploy_cmd} --item-inputs {name}='<>'" + return deploy_cmd + + +def _render_inputs( + item_info: Dict[str, Any], + default_admin_vars: List[str], + deploy_cmd: str, +) -> tuple[str, str]: details = "" - item_info_ewccli = hub_item.get(HubItemCLIKeys.ROOT.value, {}) - - for mi in item_info_ewccli.get(HubItemCLIKeys.INPUTS.value, []): - var_name = mi.get("name") - default_str = f" (default: {mi['default']})" if "default" in mi else "" - mandatory = ( - "(mandatory)" - if ("default" not in mi and var_name not in default_admin_variables) - else "(optional) " - ) - details += f"{mandatory} {var_name}: ({mi.get('type')}){default_str}: {mi.get('description')}\n" - if "default" not in mi and var_name not in default_admin_variables: - deploy_command_example += f" --item-inputs {var_name}='<>'" + for mi in item_info.get("inputs", []): + mandatory = _input_is_mandatory(mi, default_admin_vars) + default_str = _format_default(mi) - table.add_row("Inputs", details) + details += _format_input_line(mi, mandatory, default_str) + deploy_cmd = _maybe_add_to_deploy_cmd(deploy_cmd, mi, mandatory) - table.add_row("Deploy command example", deploy_command_example) + return details, deploy_cmd - deploy_command_defaults = "" - if item_info_ewccli.get(HubItemCLIKeys.DEFAULT_IMAGE_NAME.value): - deploy_command_defaults = f"Image Name: {item_info_ewccli.get(HubItemCLIKeys.DEFAULT_IMAGE_NAME.value)}\n" - if item_info_ewccli.get(HubItemCLIKeys.DEFAULT_SECURITY_GROUPS.value): - deploy_command_defaults = f"Security Group/s: {','.join([f for f in item_info_ewccli.get(HubItemCLIKeys.DEFAULT_SECURITY_GROUPS.value, [])])}\n" +def _render_defaults(item_info: Dict[str, Any]) -> str: + defaults = [] - if deploy_command_defaults: - table.add_row("Deploy command defaults", deploy_command_defaults) + image = item_info.get("defaultImageName") + if image: + defaults.append(f"Image Name: {image}") - console.print( - table, - # justify="center" + sgs = item_info.get("defaultSecurityGroups", []) + if sgs: + defaults.append(f"Security Group/s: {','.join(sgs)}") + + return "\n".join(defaults) + ("\n" if defaults else "") + + +def show_item_table( + hub_item: Dict[str, Any], + default_admin_variables_map: Optional[Dict[str, Any]] = None, +) -> None: + """Show item metadata table.""" + table = Table( + show_header=True, + show_lines=True, + header_style="bold green", + title=f"{hub_item.get('name')} EWC Item Details", + box=box.MINIMAL_DOUBLE_HEAD, + ) + table.add_column("Metadata", no_wrap=False, min_width=15) + table.add_column("Data", no_wrap=False, min_width=15) + + _add_basic_metadata(table, hub_item) + _add_maintainers(table, hub_item) + _add_annotations(table, hub_item) + _add_description(table, hub_item) + + item_info = hub_item.get("ewccli", {}) + default_admin_vars = _extract_default_admin_vars(default_admin_variables_map) + + deploy_cmd = f"ewc hub deploy {hub_item.get('name')}" + inputs_details, deploy_cmd = _render_inputs( + item_info, default_admin_vars, deploy_cmd ) + table.add_row("Inputs", inputs_details) + table.add_row("Deploy command example", deploy_cmd) + + defaults_block = _render_defaults(item_info) + if defaults_block: + table.add_row("Deploy command defaults", defaults_block) + + console.print(table) -def list_dict_table(title: str, kv: dict): + +def list_dict_table(title: str, kv: Dict[str, Any]) -> None: """List dictionary in table.""" table = Table( show_header=True, @@ -477,102 +556,140 @@ def list_dict_table(title: str, kv: dict): ) -def show_objects(title: str, objects: list, plural: str, namespace: str) -> None: - """Show objects from kubernetes backend.""" - if not objects: - click.echo(f"No {plural} found.") - return None +def _extract_metadata(item: Dict[str, Any], default_ns: str) -> tuple[str, str, str]: + metadata = item.get("metadata", {}) + name = metadata.get("name", "N/A") + namespace = metadata.get("namespace", default_ns) + creation_ts = metadata.get("creationTimestamp") + return name, namespace, creation_ts + + +def _compute_age(creation_ts: str | None, now: datetime) -> str: + if not creation_ts: + return "N/A" + + created = datetime.fromisoformat(creation_ts.replace("Z", "+00:00")) + age_seconds = (now - created).total_seconds() + + if age_seconds < 60: + value, unit = int(age_seconds), "s" + elif age_seconds < 3600: + value, unit = int(age_seconds // 60), "m" + elif age_seconds < 86400: + value, unit = int(age_seconds // 3600), "h" + else: + value, unit = int(age_seconds // 86400), "d" + + return f"{value}{unit}" + +def _extract_status(item: Dict[str, Any]) -> Any: + status_obj = item.get("status", {}) + conditions = status_obj.get("conditions", []) + return get_reason_from_conditions(conditions) + + +def _build_table(title: str) -> Table: table = Table(title=title) table.add_column("Name", style="cyan", no_wrap=True) table.add_column("Namespace", style="yellow") table.add_column("Age", style="green") table.add_column("Status", style="magenta") + return table + + +def show_objects( + title: str, objects: List[Dict[str, Any]], plural: str, namespace: str +) -> None: + """Show objects from Kubernetes backend.""" + if not objects: + click.echo(f"No {plural} found.") + return + table = _build_table(title) now = datetime.now(timezone.utc) for item in objects: - metadata = item.get("metadata", {}) - name = metadata.get("name", "N/A") - namespace = metadata.get("namespace", namespace) - creation_ts = metadata.get("creationTimestamp") - status_obj = item.get("status", {}) - conditions = status_obj.get("conditions", []) - - status = get_reason_from_conditions(conditions) - - age = "N/A" - if creation_ts: - created = datetime.fromisoformat(creation_ts.replace("Z", "+00:00")) - age_seconds = (now - created).total_seconds() - if age_seconds < 60: - age = f"{int(age_seconds)}s" - elif age_seconds < 3600: - age = f"{int(age_seconds // 60)}m" - elif age_seconds < 86400: - age = f"{int(age_seconds // 3600)}h" - else: - age = f"{int(age_seconds // 86400)}d" - - table.add_row(name, namespace, age, status) + name, ns, creation_ts = _extract_metadata(item, namespace) + age = _compute_age(creation_ts, now) + status = _extract_status(item) + table.add_row(name, ns, age, status) console.print(table) -def describe_object(obj: dict) -> None: - """ - Render a Kubernetes object (CR or built-in) in a kubectl-describe-like table. - """ - if not obj: - return None +def _flatten_dict(value: Dict[str, Any], parent: str) -> List[Tuple[str, Any]]: + items: List[Tuple[str, Any]] = [] + for key, sub in value.items(): + full_key = f"{parent}.{key}" if parent else key + items.extend(flatten_dict(sub, full_key)) + return items + + +def _flatten_list(value: List[Any], parent: str) -> List[Tuple[str, Any]]: + items: List[Tuple[str, Any]] = [] + if all(isinstance(i, dict) for i in value): + for idx, sub in enumerate(value): + items.extend(flatten_dict(sub, f"{parent}[{idx}]")) + else: + items.append((parent, ", ".join(str(i) for i in value))) + return items + + +def flatten_dict(data: Any, parent: str = "") -> List[Tuple[str, Any]]: + """Flatten nested dicts/lists into dot-notation key/value pairs.""" + if isinstance(data, dict): + return _flatten_dict(data, parent) + + if isinstance(data, list): + return _flatten_list(data, parent) + + # Base value + return [(parent, data)] + + +def render_section(title: str, content: Dict[str, Any]) -> None: + """Render a single section of a Kubernetes object.""" + if not content: + return + + click.secho(title, fg="green", bold=True) + for key, value in flatten_dict(content): + click.echo(f" {key:25} {value}") + click.echo() - def _flatten(d, parent=""): - """Flatten dicts into key: value (dot notation for nested).""" - items = [] - for k, v in d.items(): - key = f"{parent}.{k}" if parent else k - if isinstance(v, dict): - items.extend(_flatten(v, key)) - elif isinstance(v, list): - if all(isinstance(i, dict) for i in v): - for idx, sub in enumerate(v): - items.extend(_flatten(sub, f"{key}[{idx}]")) - else: - items.append((key, ", ".join(str(i) for i in v))) - else: - items.append((key, v)) - return items - # Flatten top-level metadata, spec, status - sections = { +def extract_sections(obj: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + """Extract metadata/spec/status sections from a Kubernetes object.""" + return { "Metadata": obj.get("metadata", {}), "Spec": obj.get("spec", {}), "Status": obj.get("status", {}), } - # Print header - click.secho( - f"Name: {obj.get('metadata', {}).get('name', '')}", - fg="cyan", - bold=True, - ) - click.secho( - f"Namespace: {obj.get('metadata', {}).get('namespace', '')}", fg="cyan" - ) - click.secho( - f"Kind: {obj.get('kind', '')} | API: {obj.get('apiVersion', '')}", - fg="cyan", - ) + +def describe_object(obj: Dict[str, Any]) -> None: + """ + Render a Kubernetes object (CR or built-in) in a kubectl-describe-like table. + """ + if not obj: + return + + name = obj.get("metadata", {}).get("name", "") + namespace = obj.get("metadata", {}).get("namespace", "") + kind = obj.get("kind", "") + api = obj.get("apiVersion", "") + + click.secho(f"Name: {name}", fg="cyan", bold=True) + click.secho(f"Namespace: {namespace}", fg="cyan") + click.secho(f"Kind: {kind} | API: {api}", fg="cyan") click.echo() - # Print sections - for section, content in sections.items(): - if not content: - continue - click.secho(section, fg="green", bold=True) - for key, value in _flatten(content): - click.echo(f" {key:25} {value}") - click.echo() + for section_name, content in extract_sections(obj).items(): + render_section(section_name, content) + + +# DNS methods def build_dns_record_name( diff --git a/ewccli/commands/commons_infra.py b/ewccli/commands/commons_infra.py index 907db6e..ca7f8b9 100644 --- a/ewccli/commands/commons_infra.py +++ b/ewccli/commands/commons_infra.py @@ -11,7 +11,7 @@ import sys import time from pathlib import Path -from typing import Optional, Tuple, Dict +from typing import Optional, Tuple, Dict, Any, Mapping from rich.console import Console from rich.table import Table @@ -21,9 +21,9 @@ from click import ClickException from openstack import connection -from ewccli.utils import save_encoded_ssh_keys, check_ssh_keys_match from ewccli.backends.openstack.backend_ostack import OpenstackBackend from ewccli.enums import Federee +from ewccli.ssh_keys_manager import SSHKeyManager, SSHKeyError from ewccli.configuration import config as ewc_hub_config from ewccli.logger import get_logger @@ -36,30 +36,35 @@ def check_user_ssh_keys( ssh_public_key_path: Optional[str] = None, ssh_private_key_path: Optional[str] = None, - dry_run: bool = False + dry_run: bool = False, ): """Check if SSH keys are compatible or missing.""" if dry_run: - _LOGGER.info("Dry Run enable: Skipping checking SSH private and public keys...") + _LOGGER.info("Dry run enabled: skipping SSH key validation.") return - # If still missing, raise exception - keys_exist = check_ssh_keys_exist( - ssh_private_key_path=Path(ssh_private_key_path), - ssh_public_key_path=Path(ssh_public_key_path) - ) + if not ssh_public_key_path or not ssh_private_key_path: + raise ClickException("SSH key paths must be provided for validation.") - if not keys_exist: - raise ClickException( - f"\n Exiting." - ) + manager = SSHKeyManager() - is_matching = check_ssh_keys_match( - ssh_private_key_path=ssh_private_key_path, - ssh_public_key_path=ssh_public_key_path - ) + pub_path = Path(ssh_public_key_path).expanduser() + priv_path = Path(ssh_private_key_path).expanduser() - if not is_matching: + # 1. Check existence + try: + manager.keys_exist(priv_path, pub_path) + except SSHKeyError as exc: + console.print(Panel(str(exc), title="SSH Key Check Failed", style="red")) + raise ClickException("Exiting.") + + # 2. Check matching + try: + matching = manager.keys_match(priv_path, pub_path) + except Exception as exc: + raise ClickException(f"Failed to validate SSH keys: {exc}") from exc + + if not matching: raise ClickException( "SSH keys provided are not a correct keypair:" f"\nSSH public key path: {ssh_public_key_path}" @@ -67,8 +72,8 @@ def check_user_ssh_keys( "\nMake sure either you pass correct SSH keypair in the EWC login command through the following flags `--ssh-private-key-path` and `--ssh-public-key-path`" "or let the `ewc login` command create them for you. Exiting." ) - else: - _LOGGER.info("SSH private and public keys are matching! Continuing...") + + _LOGGER.info("SSH private and public keys are matching! Continuing...") def check_server_conflict_with_inputs( @@ -185,58 +190,6 @@ def show_server_inputs_difference_table(server_name: str, diffs: dict): ) -def check_ssh_keys_exist(ssh_public_key_path: Path, ssh_private_key_path: Path) -> bool: - """ - Verifies the existence of the specified SSH key files. - - If either the private or public key file does not exist at the given paths, - raises a FileNotFoundError with detailed instructions for resolution. - - Parameters: - ssh_private_key_path (Path): Path to the private SSH key file. - ssh_public_key_path (Path): Path to the public SSH key file. - - Raises: - FileNotFoundError: If one or both SSH key files are missing. - - Environment Variables: - EWC_CLI_SSH_PRIVATE_KEY_PATH - optional custom path for the private SSH key. - EWC_CLI_SSH_PUBLIC_KEY_PATH - optional custom path for the public SSH key. - - Example: - >>> check_ssh_keys_exist(Path("~/.ssh/id_rsa"), Path("~/.ssh/id_rsa.pub")) - """ - missing_msgs = [] - - if not ssh_private_key_path.is_file(): - missing_msgs.append( - f"🔒 [bold red]Missing Private Key:[/bold red] {ssh_private_key_path}" - ) - if not ssh_public_key_path.is_file(): - missing_msgs.append( - f"🔓 [bold red]Missing Public Key:[/bold red] {ssh_public_key_path}" - ) - - if missing_msgs: - panel_content = ( - "\n".join(missing_msgs) - + "\n\n" - + "[bold yellow]Tip:[/bold yellow] You can run ewc login and create them.\n" - + "[bold yellow]Tip:[/bold yellow] You can specify custom paths with:\n" - + '[green]export EWC_CLI_SSH_PRIVATE_KEY_PATH="/path/to/id_rsa"[/green]\n' - + '[green]export EWC_CLI_SSH_PUBLIC_KEY_PATH="/path/to/id_rsa.pub"[/green]' - ) - - console.print( - Panel( - panel_content, title="SSH Key Check Failed", style="red", expand=False - ) - ) - return False - - return True - - def normalize_os_image(image_name: str, federee: str) -> tuple[str | None, bool]: """ Normalize OS image names provided. @@ -373,11 +326,15 @@ def resolve_image_and_flavor( flavour_name = ewc_hub_config.DEFAULT_CPU_FLAVOURS_MAP.get(federee) # Normalize the image name - normalized_image_name, is_short_name = normalize_os_image(image_name=image_name, federee=federee) + normalized_image_name, is_short_name = normalize_os_image( + image_name=image_name, federee=federee + ) # Now check the image provided and verify is supported. if not normalized_image_name: - total_images = ewc_hub_config.EWC_CLI_CPU_IMAGES + [ewc_hub_config.EWC_CLI_GPU_IMAGES_SITE_MAP[federee]] + total_images = ewc_hub_config.EWC_CLI_CPU_IMAGES + [ + ewc_hub_config.EWC_CLI_GPU_IMAGES_SITE_MAP[federee] + ] error_message = ( f"❌ Unsupported OS image for the EWC CLI: {image_name}\n\n" f"đŸ–Ĩī¸ EWC Supported images (short names): [bold green]{', '.join(total_images)}[/bold green]\n" @@ -386,12 +343,16 @@ def resolve_image_and_flavor( ) return 1, f"Unexpected error: {error_message}", result - + # Retrieve the latest image latest_image = openstack_backend.find_latest_image(conn, normalized_image_name) - + # if users use long names, let's check if they are using the latest known image and give them a warning in case. - if image_name not in [ewc_hub_config.EWC_CLI_GPU_IMAGES_SITE_MAP[federee]] and not is_short_name and latest_image: + if ( + image_name not in [ewc_hub_config.EWC_CLI_GPU_IMAGES_SITE_MAP[federee]] + and not is_short_name + and latest_image + ): if latest_image.name != image_name: _LOGGER.warning( f"You are not using latest image for {image_name}." @@ -431,7 +392,7 @@ def resolve_image_and_flavor( def resolve_machine_ip( federee: str, - server_info: dict, + server_info: Any, ) -> Tuple[int, str, Optional[Dict[str, Optional[str]]]]: """ Resolve the internal and external IPs of a machine. @@ -596,16 +557,16 @@ def pre_deploy_server_setup( ssh_private_encoded: Optional[str] = None, ssh_public_encoded: Optional[str] = None, dry_run: bool = False, - force: bool = False, + force: bool = False, ): """Pre deploy server setup steps: - - check SSH keys - - select correct image and flavour - - select correct network - - verify all inputs for the resources are valid - - get or create keypair - + - check SSH keys + - select correct image and flavour + - select correct network + - verify all inputs for the resources are valid + - get or create keypair + """ outputs: dict[str, Optional[str]] = {} @@ -614,52 +575,74 @@ def pre_deploy_server_setup( image_name: Optional[str] = server_inputs["image_name"] flavour_name: Optional[str] = server_inputs["flavour_name"] security_groups: Optional[tuple] = server_inputs["security_groups"] - item_default_security_groups: Optional[tuple] = server_inputs["item_default_security_groups"] + item_default_security_groups: Optional[tuple] = server_inputs[ + "item_default_security_groups" + ] if dry_run: return 0, "[Dry Run] skipping pre deploy server setup...", outputs - _LOGGER.info(f"Pre deploy server setup starting...") + _LOGGER.info("Pre deploy server setup starting...") + + manager = SSHKeyManager() if ssh_public_encoded or ssh_private_encoded: if ssh_public_encoded: - ssh_public_key_path = ewc_hub_config.EWC_CLI_HUB_SSH_REPO_PATH / f"tmp_encoded_public_key_{keypair_name}" + ssh_public_key_path = ( + ewc_hub_config.EWC_CLI_HUB_SSH_REPO_PATH + / f"tmp_encoded_public_key_{keypair_name}" + ) if ssh_private_encoded: - ssh_private_key_path = ewc_hub_config.EWC_CLI_HUB_SSH_REPO_PATH / f"tmp_encoded_private_key_{keypair_name}" + ssh_private_key_path = ( + ewc_hub_config.EWC_CLI_HUB_SSH_REPO_PATH + / f"tmp_encoded_private_key_{keypair_name}" + ) - public_written, private_written = save_encoded_ssh_keys( + public_written, private_written = manager.save_encoded_keys( ssh_public_key_path=ssh_public_key_path, ssh_private_key_path=ssh_private_key_path, ssh_public_encoded=ssh_public_encoded, - ssh_private_encoded=ssh_private_encoded + ssh_private_encoded=ssh_private_encoded, ) # Only validate keys if both were successfully written if public_written and private_written: check_user_ssh_keys( ssh_public_key_path=ssh_public_key_path, - ssh_private_key_path=ssh_private_key_path + ssh_private_key_path=ssh_private_key_path, ) # Casw 2: one valid → tell me which one elif public_written and not private_written: - return 1, f"[Pre deploy server setup] Invalid encoded private key: could not decode or write private key.", outputs + return ( + 1, + "[Pre deploy server setup] Invalid encoded private key: could not decode or write private key.", + outputs, + ) elif private_written and not public_written: - return 1, f"[Pre deploy server setup] Invalid encoded public key: could not decode or write public key.", outputs + return ( + 1, + "[Pre deploy server setup] Invalid encoded public key: could not decode or write public key.", + outputs, + ) # Case 3: None valid → fail. else: - return 1, f"[Pre deploy server setup] Both encoded SSH keys are invalid: cannot decode or write either key.", outputs + return ( + 1, + "[Pre deploy server setup] Both encoded SSH keys are invalid: cannot decode or write either key.", + outputs, + ) - keys_exist = check_ssh_keys_exist( + keys_exist = manager.keys_exist( ssh_public_key_path=Path(ssh_public_key_path), ssh_private_key_path=Path(ssh_private_key_path), ) if not keys_exist: - return 1, f"\n[Pre deploy server setup] Exiting.", outputs + return 1, "\n[Pre deploy server setup] Exiting.", outputs ################################################################################## # Flavour and Image @@ -670,7 +653,7 @@ def pre_deploy_server_setup( federee=federee, flavour_name=flavour_name, image_name=image_name, - is_gpu=is_gpu + is_gpu=is_gpu, ) if sc != 0 or not resolved_info: return 1, f"[Pre deploy server setup] {resolve_message}", outputs @@ -706,8 +689,8 @@ def pre_deploy_server_setup( outputs["networks"] = networks - security_groups = security_groups_inputs or ewc_hub_config.DEFAULT_SECURITY_GROUP_MAP.get( - federee + security_groups = ( + security_groups_inputs or ewc_hub_config.DEFAULT_SECURITY_GROUP_MAP.get(federee) ) if not security_groups: @@ -735,7 +718,11 @@ def pre_deploy_server_setup( outputs, ) except Exception as e: - return 1, f"[Pre deploy server setup] Could not check inputs from Openstack due to {e}", outputs + return ( + 1, + f"[Pre deploy server setup] Could not check inputs from Openstack due to {e}", + outputs, + ) ################################################################################# # Get or Create keypair @@ -761,13 +748,13 @@ def pre_deploy_server_setup( else: _LOGGER.info(key_pair_message) - return 0, f"Pre deploy server setup finished successfully.", outputs + return 0, "Pre deploy server setup finished successfully.", outputs def identify_server_reconfiguration( openstack_api: connection.Connection, server_inputs: dict, - pre_deploy_server_outputs: dict + pre_deploy_server_outputs: dict, ): """Identify resources to be reconfigured.""" outputs: dict[str, Optional[str]] = {} @@ -825,13 +812,11 @@ def identify_server_reconfiguration( ) if diffs: - show_server_inputs_difference_table( - server_name=server_name, diffs=diffs - ) + show_server_inputs_difference_table(server_name=server_name, diffs=diffs) return ( 0, - f"No reconfiguration needed", + "No reconfiguration needed", outputs, ) @@ -840,13 +825,13 @@ def deploy_server( openstack_backend: OpenstackBackend, openstack_api: connection.Connection, federee: str, - server_inputs: dict, - pre_deploy_server_outputs: dict, + server_inputs: Dict[str, Any], + pre_deploy_server_outputs: Dict[str, Any], dry_run: bool = False, force: bool = False, -): +) -> Tuple[int, Optional[str], Optional[Dict[str, Any]]]: """Deploy Server in Openstack.""" - outputs: dict[str, Optional[str]] = {} + outputs: Optional[Dict[str, Any]] = {} if dry_run: return 0, "Dry run: skipping deploy server...", outputs @@ -872,7 +857,9 @@ def deploy_server( # Get or Create Server ################################################################################# if force: - _LOGGER.warning("[Deploy server] Force enabled, server will be deleted first, if existing.") + _LOGGER.warning( + "[Deploy server] Force enabled, server will be deleted first, if existing." + ) openstack_server_status, delete_server_message = ( openstack_backend.delete_server(conn=openstack_api, server_name=server_name) @@ -939,22 +926,22 @@ def post_deploy_server_setup( openstack_backend: OpenstackBackend, openstack_api: connection.Connection, federee: str, - server_inputs: dict, - server_info: dict, + server_inputs: Dict[str, Any], + server_info: Dict[str, Any], dry_run: bool = False, -): +) -> Tuple[int, str, Optional[Dict[str, Any]]]: """Post deploy server setup steps: - - attach floating IP - - attach volume - + - attach floating IP + - attach volume + """ - outputs: dict[str, Optional[str]] = {} + outputs: Optional[Dict[str, Any]] = {} if dry_run: return 0, "[Dry Run] skipping post deploy server setup...", outputs - _LOGGER.info(f"Post deploy server setup starting...") + _LOGGER.info("Post deploy server setup starting...") server_name: str = server_inputs["server_name"] @@ -985,10 +972,10 @@ def post_deploy_server_setup( else: _LOGGER.info(message) - server_info = openstack_api.get_server(name_or_id=server_name) + server_info_with_ip: Any = openstack_api.get_server(name_or_id=server_name) sc_resolve_ip, resolve_ip_message, resolve_ip_outputs = resolve_machine_ip( - federee=federee, server_info=server_info + federee=federee, server_info=server_info_with_ip ) if sc_resolve_ip != 0: return 1, resolve_ip_message, outputs @@ -1011,7 +998,7 @@ def post_deploy_server_setup( outputs = { "internal_ip_machine": internal_ip_machine, "external_ip_machine": external_ip_machine, - "server_info": server_info, + "server_info": server_info_with_ip, } return 0, "Post deploy server setup finished successfully", outputs @@ -1021,14 +1008,14 @@ def create_server_command( openstack_backend: OpenstackBackend, openstack_api: connection.Connection, federee: str, - server_inputs: dict, + server_inputs: Dict[str, Any], ssh_public_key_path: str, ssh_private_key_path: str, ssh_private_encoded: Optional[str] = None, ssh_public_encoded: Optional[str] = None, dry_run: bool = False, - force: bool = False, -): + force: bool = False, +) -> Tuple[int, str, Dict[str, Any]]: """Create Server command.""" #### PRE DEPLOY SERVER ACTION os_status_code, os_message, pre_deploy_server_outputs = pre_deploy_server_setup( @@ -1041,7 +1028,7 @@ def create_server_command( ssh_public_key_path=ssh_public_key_path, ssh_private_key_path=ssh_private_key_path, dry_run=dry_run, - force=force, + force=force, ) if os_status_code != 0 or not pre_deploy_server_outputs: @@ -1049,7 +1036,9 @@ def create_server_command( # Exit with a non-zero status sys.exit(1) - server_inputs["normalized_image_name"] = pre_deploy_server_outputs["normalized_image_name"] + server_inputs["normalized_image_name"] = pre_deploy_server_outputs[ + "normalized_image_name" + ] if "networks" in pre_deploy_server_outputs: server_inputs["networks"] = pre_deploy_server_outputs["networks"] @@ -1061,7 +1050,7 @@ def create_server_command( identify_server_reconfiguration( openstack_api=openstack_api, server_inputs=server_inputs, - pre_deploy_server_outputs=pre_deploy_server_outputs + pre_deploy_server_outputs=pre_deploy_server_outputs, ) #### DEPLOY SERVER ACTION @@ -1090,6 +1079,11 @@ def create_server_command( dry_run=dry_run, ) + if not post_deploy_server_outputs: + console.print(Panel(os_message, title="Error", style="red")) + # Exit with a non-zero status + sys.exit(1) + internal_ip_machine = post_deploy_server_outputs["internal_ip_machine"] external_ip_machine = post_deploy_server_outputs["external_ip_machine"] diff --git a/ewccli/commands/hub/hub_command.py b/ewccli/commands/hub/hub_command.py index 01456ee..c6b1800 100644 --- a/ewccli/commands/hub/hub_command.py +++ b/ewccli/commands/hub/hub_command.py @@ -49,7 +49,7 @@ from ewccli.enums import HubItemCategoryAnnotation from ewccli.enums import HubItemCLIKeys from ewccli.logger import get_logger -from ewccli.utils import load_cli_profile +from ewccli.profile import ProfileStore _LOGGER = get_logger(__name__) @@ -77,11 +77,15 @@ def ewc_hub_command(ctx, path_to_catalog): if path_to_catalog: if not path_to_catalog.exists(): - raise click.ClickException(f"Catalog file doesn't exist at this path: {path_to_catalog}") + raise click.ClickException( + f"Catalog file doesn't exist at this path: {path_to_catalog}" + ) # Check directory: if path_to_catalog.is_dir(): - raise click.ClickException(f"Catalog path must be a file not a directory: {path_to_catalog}") + raise click.ClickException( + f"Catalog path must be a file not a directory: {path_to_catalog}" + ) items = load_hub_items(path_to_catalog=path_to_catalog) @@ -89,16 +93,12 @@ def ewc_hub_command(ctx, path_to_catalog): items = load_hub_items() # Store the option to make it available to all subcommands - ctx.obj['items'] = items + ctx.obj["items"] = items - ctx.obj['cli_profile'] = None + ctx.obj["cli_profile"] = None -def categorize_item_inputs( - ctx, - item_info: dict, - item_info_inputs: list -): # noqa CCR001 +def categorize_item_inputs(ctx, item_info: dict, item_info_inputs: list): # noqa CCR001 """Categorize item inputs into default and mandatory.""" default_inputs = [] required_inputs = [] @@ -251,6 +251,7 @@ def convert(self, value, param, ctx): " When passing lists or dictionaries, the syntax used to parse inputs is same as yaml.\n" ) + def _validate_item_inputs_format(ctx, param, values): if not values: return {} @@ -365,34 +366,30 @@ def deploy_cmd( # noqa: CFQ002, CFQ001, CCR001, C901 if dry_run: _LOGGER.info("Dry run enabled...") + store = ProfileStore() + if profile: - cli_profile = load_cli_profile( - profile=profile, - dry_run=dry_run - ) + cli_profile = store.load(name=profile) else: # Use default profile if exists - cli_profile = load_cli_profile( - profile=ewc_hub_config.EWC_CLI_DEFAULT_PROFILE_NAME, - dry_run=dry_run - ) + cli_profile = store.load(name=ewc_hub_config.EWC_CLI_DEFAULT_PROFILE_NAME) - _LOGGER.info(f"Using `{cli_profile.get('profile')}` profile.") + _LOGGER.info(f"Using `{cli_profile.profile}` profile.") tenancy_name: str = cli_profile["tenant_name"] federee: str = cli_profile["federee"] # Try to fill from CLI profile if not provided if not ssh_public_key_path: - ssh_public_key_path = cli_profile.get("ssh_public_key_path") + ssh_public_key_path = cli_profile.ssh_public_key_path if not ssh_private_key_path: - ssh_private_key_path = cli_profile.get("ssh_private_key_path") + ssh_private_key_path = cli_profile.ssh_private_key_path check_user_ssh_keys( ssh_public_key_path=ssh_public_key_path, ssh_private_key_path=ssh_private_key_path, - dry_run=dry_run + dry_run=dry_run, ) # Take item information @@ -404,7 +401,7 @@ def deploy_cmd( # noqa: CFQ002, CFQ001, CCR001, C901 item = os.getenv("EWC_CLI_HUB_ITEM") or item console.print(f"You selected {item} item from the EWC Community Hub.") - item_info = ctx.obj['items'][item] + item_info = ctx.obj["items"][item] # Retrieve item inputs of the selected item from the catalogue item_info_ewccli = item_info.get(HubItemCLIKeys.ROOT.value, {}) @@ -429,10 +426,7 @@ def deploy_cmd( # noqa: CFQ002, CFQ001, CCR001, C901 if missing_keys: message = prepare_missing_inputs_error_message(missing_keys) - raise click.UsageError( - f"{message}\n\n" - f"{_ITEM_INPUT_MESSAGE}" - ) + raise click.UsageError(f"{message}\n\n{_ITEM_INPUT_MESSAGE}") ##################################################################################### # Prepare item parameters @@ -503,7 +497,9 @@ def deploy_cmd( # noqa: CFQ002, CFQ001, CCR001, C901 raise ClickException(error_message) if not working_directory_path: - raise ClickException(f"Working directory path is empty, please verify sources metadata in your hub catalogue for {item} item") + raise ClickException( + f"Working directory path is empty, please verify sources metadata in your hub catalogue for {item} item" + ) ######################################################################## # Run logic based on the technology annotation of the item @@ -524,17 +520,23 @@ def deploy_cmd( # noqa: CFQ002, CFQ001, CCR001, C901 ) application_credential_id = ( - cli_profile.get("application_credential_id") or application_credential_id + cli_profile.application_credential_id or application_credential_id ) application_credential_secret = ( - cli_profile.get("application_credential_secret") + cli_profile.application_credential_secret or application_credential_secret ) if not auth_url: auth_url = ewc_hub_config.EWC_CLI_SITE_MAP.get(federee) if dry_run: - console.print(Panel(f"Dry run: skipping OpenStack connection and exiting.", title="Info", style="green")) + console.print( + Panel( + "Dry run: skipping OpenStack connection and exiting.", + title="Info", + style="green", + ) + ) sys.exit(0) try: @@ -613,7 +615,9 @@ def deploy_cmd( # noqa: CFQ002, CFQ001, CCR001, C901 server_inputs = { "server_name": server_name, "is_gpu": is_gpu, - "image_name": item_info_ewccli.get(HubItemCLIKeys.DEFAULT_IMAGE_NAME.value) if not image_name else image_name, + "image_name": item_info_ewccli.get(HubItemCLIKeys.DEFAULT_IMAGE_NAME.value) + if not image_name + else image_name, "keypair_name": keypair_name, "flavour_name": flavour_name, "external_ip": external_ip @@ -622,7 +626,7 @@ def deploy_cmd( # noqa: CFQ002, CFQ001, CCR001, C901 "security_groups": security_groups, "item_default_security_groups": item_info_ewccli.get( HubItemCLIKeys.DEFAULT_SECURITY_GROUPS.value - ) + ), } os_status_code, os_message, outputs = create_server_command( @@ -635,7 +639,7 @@ def deploy_cmd( # noqa: CFQ002, CFQ001, CCR001, C901 ssh_public_key_path=ssh_public_key_path, ssh_private_key_path=ssh_private_key_path, dry_run=dry_run, - force=force, + force=force, ) internal_ip_machine = outputs["internal_ip_machine"] @@ -678,9 +682,7 @@ def deploy_cmd( # noqa: CFQ002, CFQ001, CCR001, C901 #### ANSIBLE PLAYBOOK ITEM DEPLOYMENT ####################################################################################### - username = ( - ewc_hub_config.EWC_CLI_IMAGES_USER.get(normalized_image_name) - ) + username = ewc_hub_config.EWC_CLI_IMAGES_USER.get(normalized_image_name) # If missing the mapping in the configuration is missing, so configuration file needs to be checked. if not username: @@ -688,8 +690,9 @@ def deploy_cmd( # noqa: CFQ002, CFQ001, CCR001, C901 Panel( f"[Ansible Item] username for {normalized_image_name} could not be identified.", title="Error", - style="red") + style="red", ) + ) # Exit with a non-zero status sys.exit(1) @@ -799,7 +802,7 @@ def list_cmd(ctx, force: bool): if force: download_items(force=force) - list_items_table(hub_items=ctx.obj['items']) + list_items_table(hub_items=ctx.obj["items"]) @ewc_hub_command.command("show") @@ -815,9 +818,9 @@ def show_cmd(ctx, item): where is taken from ewc hub list command. """ - if item not in [i for i, _ in ctx.obj['items'].items()]: + if item not in [i for i, _ in ctx.obj["items"].items()]: list_items_table( - hub_items=ctx.obj['items'], + hub_items=ctx.obj["items"], ) raise ClickException( f"{item} is not available in the EWC Hub. Please check the list above." @@ -825,6 +828,6 @@ def show_cmd(ctx, item): else: show_item_table( - hub_item=ctx.obj['items'].get(item), + hub_item=ctx.obj["items"].get(item), default_admin_variables_map=HUB_ENV_VARIABLES_MAP, ) diff --git a/ewccli/commands/hub/hub_utils.py b/ewccli/commands/hub/hub_utils.py index ab3c638..d636f55 100644 --- a/ewccli/commands/hub/hub_utils.py +++ b/ewccli/commands/hub/hub_utils.py @@ -79,7 +79,7 @@ def classify_source(source: str) -> str: - 'github' → GitHub HTTPS repo URL compatible with check_github_repo_accessible() - 'directory' → local directory - 'unknown' → neither - + Notes: - GitHub URLs must be HTTPS and like: https://github.com/user/repo @@ -96,7 +96,9 @@ def classify_source(source: str) -> str: return "directory" # 3. Unknown - raise click.BadParameter(f"Source provided: {source} is not a valid GitHub repo URL or an absolute path to a local directory with content.") + raise click.BadParameter( + f"Source provided: {source} is not a valid GitHub repo URL or an absolute path to a local directory with content." + ) def is_github_https_url(source: str) -> bool: diff --git a/ewccli/commands/infra_command.py b/ewccli/commands/infra_command.py index cba9cb2..f71d9bf 100644 --- a/ewccli/commands/infra_command.py +++ b/ewccli/commands/infra_command.py @@ -8,10 +8,15 @@ """EWC CLI: VM interaction.""" +from __future__ import annotations + import sys import os from typing import Optional +from typing import Tuple, Dict, Any +from pydantic import BaseModel + import rich_click as click from rich.console import Console from rich.table import Table @@ -30,7 +35,7 @@ from ewccli.commands.commons_infra import check_user_ssh_keys from ewccli.commands.commons_infra import get_deployed_server_info, list_server_details from ewccli.commands.commons_infra import create_server_command -from ewccli.utils import load_cli_profile +from ewccli.profile import ProfileStore from ewccli.logger import get_logger _LOGGER = get_logger(__name__) @@ -41,23 +46,23 @@ # Command Group -@click.group(name="infra") -@infra_context +@click.group(name="infra") # type: ignore[misc] +@infra_context # type: ignore[misc] @login_options -def ewc_infra_command(ctx, profile): +def ewc_infra_command(ctx: click.Context, profile: str) -> None: """EWC Infrastructure commands group.""" + store = ProfileStore() + if profile: - ctx.cli_profile = load_cli_profile(profile=profile) + ctx.cli_profile = store.load(name=profile) _LOGGER.info(f"Using `{profile}` profile.") else: - ctx.cli_profile = load_cli_profile( - profile=ewc_hub_config.EWC_CLI_DEFAULT_PROFILE_NAME - ) - _LOGGER.info(f"Using `{ctx.cli_profile.get('profile')}` profile.") + ctx.cli_profile = store.load(name=ewc_hub_config.EWC_CLI_DEFAULT_PROFILE_NAME) + _LOGGER.info(f"Using `{ctx.cli_profile.profile}` profile.") - federee = ctx.cli_profile.get("federee") - application_credential_id = ctx.cli_profile.get("application_credential_id") - application_credential_secret = ctx.cli_profile.get("application_credential_secret") + federee = ctx.cli_profile.federee + application_credential_id = ctx.cli_profile.application_credential_id + application_credential_secret = ctx.cli_profile.application_credential_secret ctx.openstack_backend = OpenstackBackend( application_credential_id=application_credential_id, application_credential_secret=application_credential_secret, @@ -65,7 +70,7 @@ def ewc_infra_command(ctx, profile): ) -def list_server_table(servers: dict): +def list_server_table(servers: Dict[str, Any]) -> None: """List servers in a table with columns Name, Status, and Networks.""" console = Console() @@ -91,64 +96,80 @@ def list_server_table(servers: dict): console.print(table) -@ewc_infra_command.command("create", help="Create server in Openstack.") -@infra_context +class ServerAuthConfig(BaseModel): # type: ignore[misc] + federee: Optional[str] = None + auth_url: Optional[str] = None + application_credential_id: Optional[str] = None + application_credential_secret: Optional[str] = None + + +class ServerSSHConfig(BaseModel): # type: ignore[misc] + keypair_name: str + ssh_public_key_path: Optional[str] = None + ssh_private_key_path: Optional[str] = None + ssh_private_encoded: Optional[str] = None + ssh_public_encoded: Optional[str] = None + + +class ServerNetworkConfig(BaseModel): # type: ignore[misc] + external_ip: bool = False + networks: Optional[Tuple[str, ...]] = None + security_groups: Optional[Tuple[str, ...]] = None + + +class ServerCreateOptions(BaseModel): # type: ignore[misc] + image_name: Optional[str] = None + flavour_name: Optional[str] = None + dry_run: bool = False + force: bool = False + + +@ewc_infra_command.command("create", help="Create server in Openstack.") # type: ignore[misc] +@infra_context # type: ignore[misc] @ssh_options @ssh_options_encoded @openstack_options @openstack_optional_options -@click.option( +@click.option( # type: ignore[misc] "--dry-run", envvar="EWC_CLI_DRY_RUN", default=False, is_flag=True, help="Simulate deployment without running.", ) -@click.option( +@click.option( # type: ignore[misc] "--force", envvar="EWC_CLI_FORCE", is_flag=True, default=False, help="Force item recreation operation.", ) -@click.argument("server_name") -def create_cmd( - ctx, - server_name, - dry_run: bool, - force: bool, - keypair_name: str, - ssh_public_key_path: Optional[str] = None, - ssh_private_key_path: Optional[str] = None, - federee: Optional[str] = None, - auth_url: Optional[str] = None, - application_credential_id: Optional[str] = None, - application_credential_secret: Optional[str] = None, - image_name: Optional[str] = None, - flavour_name: Optional[str] = None, - external_ip: bool = False, - networks: Optional[tuple] = None, - security_groups: Optional[tuple] = None, - ssh_private_encoded: Optional[str] = None, - ssh_public_encoded: Optional[str] = None, -): +@click.argument("server_name") # type: ignore[misc] +def create_cmd( # noqa: CFQ001, CCR001 + ctx: click.Context, + server_name: str, + auth: ServerAuthConfig, + ssh: ServerSSHConfig, + net: ServerNetworkConfig, + opts: ServerCreateOptions, +) -> None: """Show Server from Openstack.""" - if dry_run: + if opts.dry_run: _LOGGER.info("Dry run enabled...") cli_profile = ctx.cli_profile - federee = federee or cli_profile["federee"] + federee = auth.federee or cli_profile["federee"] # Try to fill from CLI profile if not provided - if not ssh_public_key_path: + if not ssh.ssh_public_key_path: ssh_public_key_path = cli_profile.get("ssh_public_key_path") - if not ssh_private_key_path: + if not ssh.ssh_private_key_path: ssh_private_key_path = cli_profile.get("ssh_private_key_path") check_user_ssh_keys( ssh_public_key_path=ssh_public_key_path, - ssh_private_key_path=ssh_private_key_path + ssh_private_key_path=ssh_private_key_path, ) _LOGGER.info(f"The server will be deployed on {federee} side of the EWC.") @@ -160,9 +181,9 @@ def create_cmd( try: # Step 1: Authenticate and initialize the OpenStack connection openstack_api = ctx.openstack_backend.connect( - auth_url=auth_url, - application_credential_id=application_credential_id, - application_credential_secret=application_credential_secret, + auth_url=auth.auth_url, + application_credential_id=auth.application_credential_id, + application_credential_secret=auth.application_credential_secret, ) except Exception as op_error: raise ClickException( @@ -172,12 +193,12 @@ def create_cmd( server_inputs = { "server_name": server_name, "is_gpu": None, - "image_name": image_name, - "keypair_name": keypair_name, - "flavour_name": flavour_name, - "external_ip": external_ip, - "networks": networks, - "security_groups": security_groups + "image_name": opts.image_name, + "keypair_name": ssh.keypair_name, + "flavour_name": opts.flavour_name, + "external_ip": net.external_ip, + "networks": net.networks, + "security_groups": net.security_groups, } os_status_code, os_message, outputs = create_server_command( @@ -185,19 +206,20 @@ def create_cmd( openstack_api=openstack_api, federee=federee, server_inputs=server_inputs, - ssh_private_encoded=ssh_private_encoded, - ssh_public_encoded=ssh_public_encoded, + ssh_private_encoded=ssh.ssh_private_encoded, + ssh_public_encoded=ssh.ssh_public_encoded, ssh_public_key_path=ssh_public_key_path, ssh_private_key_path=ssh_private_key_path, - dry_run=dry_run, - force=force, + dry_run=opts.dry_run, + force=opts.force, ) + internal_ip_machine = outputs["internal_ip_machine"] external_ip_machine = outputs["external_ip_machine"] - normalized_image_name = outputs.get("normalized_image_name") + normalized_image_name = outputs["normalized_image_name"] - username = ( - ewc_hub_config.EWC_CLI_IMAGES_USER.get(normalized_image_name) + username: Optional[str] = ewc_hub_config.EWC_CLI_IMAGES_USER.get( + normalized_image_name ) # If missing the mapping in the configuration is missing, so configuration file needs to be checked. @@ -206,8 +228,9 @@ def create_cmd( Panel( f"[Ansible Item] username for {normalized_image_name} could not be identified.", title="Error", - style="red") + style="red", ) + ) # Exit with a non-zero status sys.exit(1) @@ -218,7 +241,7 @@ def create_cmd( message = "[bold blue]🚀 Deployment Complete[/bold blue]\n" message += f"[bold]Item:[/bold] {server_name} server has been successfully deployed.\n\n" - if not external_ip: + if not net.external_ip: if not external_ip_machine: initial_message_ip = ( "[bold yellow]âš ī¸ No external IP requested[/bold yellow]\n" @@ -242,18 +265,18 @@ def create_cmd( console.print(message) -@ewc_infra_command.command("show", help="Show Openstack server information.") -@infra_context +@ewc_infra_command.command("show", help="Show Openstack server information.") # type: ignore[misc] +@infra_context # type: ignore[misc] @openstack_options -@click.argument("server_name") +@click.argument("server_name") # type: ignore[misc] def show_cmd( - ctx, - server_name, + ctx: click.Context, + server_name: str, federee: Optional[str] = None, auth_url: Optional[str] = None, application_credential_id: Optional[str] = None, application_credential_secret: Optional[str] = None, -): +) -> None: """Show Server from Openstack.""" federee = federee or ctx.cli_profile["federee"] @@ -295,10 +318,10 @@ def show_cmd( list_server_details(vm_info) -@ewc_infra_command.command(name="list", help="List servers in Openstack.") -@infra_context +@ewc_infra_command.command(name="list", help="List servers in Openstack.") # type: ignore[misc] +@infra_context # type: ignore[misc] @openstack_options -@click.option( +@click.option( # type: ignore[misc] "--show-all", is_flag=True, default=False, @@ -307,13 +330,13 @@ def show_cmd( help="List machines even if not created by the EWC CLI.", ) def list_cmd( - ctx, + ctx: click.Context, federee: Optional[str] = None, auth_url: Optional[str] = None, application_credential_id: Optional[str] = None, application_credential_secret: Optional[str] = None, show_all: bool = False, -): +) -> None: """List Servers from Openstack.""" federee = federee or ctx.cli_profile["federee"] @@ -340,18 +363,26 @@ def list_cmd( list_server_table(servers=servers) -@ewc_infra_command.command(name="delete", help="Delete server in Openstack.") -@click.option( +class ServerDeleteOptions(BaseModel): # type: ignore[misc] + dry_run: bool = False + force: bool = False + auth_url: Optional[str] = None + application_credential_id: Optional[str] = None + application_credential_secret: Optional[str] = None + + +@ewc_infra_command.command(name="delete", help="Delete server in Openstack.") # type: ignore[misc] +@click.option( # type: ignore[misc] "--dry-run", is_flag=True, default=False, help="Simulate the operation without making any changes.", ) -@click.argument( +@click.argument( # type: ignore[misc] "server-name", type=str, ) -@click.option( +@click.option( # type: ignore[misc] "--force", is_flag=True, default=False, @@ -359,25 +390,17 @@ def list_cmd( show_default=True, help="Force deletion of machines not created by the ewccli.", ) -@infra_context +@infra_context # type: ignore[misc] @openstack_options -def delete_cmd( - ctx, - server_name: str, - force: bool = False, - auth_url: Optional[str] = None, - application_credential_id: Optional[str] = None, - application_credential_secret: Optional[str] = None, - dry_run: bool = False, -): +def delete_cmd(ctx: click.Context, server_name: str, opts: ServerDeleteOptions) -> None: """Delete VM from Openstack.""" # Step 1: Authenticate and initialize the OpenStack connection try: # Step 1: Authenticate and initialize the OpenStack connection openstack_api = ctx.openstack_backend.connect( - auth_url=auth_url, - application_credential_id=application_credential_id, - application_credential_secret=application_credential_secret, + auth_url=opts.auth_url, + application_credential_id=opts.application_credential_id, + application_credential_secret=opts.application_credential_secret, ) except Exception as op_error: raise ClickException( @@ -388,7 +411,10 @@ def delete_cmd( try: ctx.openstack_backend.delete_server( - conn=openstack_api, server_name=server_name, force=force, dry_run=dry_run + conn=openstack_api, + server_name=server_name, + force=opts.force, + dry_run=opts.dry_run, ) except Exception as e: raise ClickException( diff --git a/ewccli/commands/login_command.py b/ewccli/commands/login_command.py index 6e4157b..6cabf2c 100644 --- a/ewccli/commands/login_command.py +++ b/ewccli/commands/login_command.py @@ -12,13 +12,15 @@ import re from typing import Optional from pathlib import Path +from typing import Callable, Any +from typing import NoReturn import rich_click as click from rich.console import Console +from rich.panel import Panel from click import ClickException -from configparser import ConfigParser - +from prompt_toolkit.key_binding import KeyPressEvent from prompt_toolkit.application import Application from prompt_toolkit.widgets import RadioList, Box, Frame from prompt_toolkit.layout import Layout @@ -32,11 +34,10 @@ from openstack.exceptions import ( # noqa: N813 ConfigException as openstack_config_exception, ) - +from ewccli.configuration import LoginInput from ewccli.configuration import config as ewc_hub_config -from ewccli.utils import save_cli_profile, _resolve_profile, load_cli_profile -from ewccli.utils import generate_ssh_keypair, check_ssh_keys_match -from ewccli.utils import save_default_login_profile +from ewccli.profile import ProfileData, ProfileStore +from ewccli.ssh_keys_manager import SSHKeyManager, SSHKeyError from ewccli.enums import Federee from ewccli.logger import get_logger @@ -46,7 +47,7 @@ console = Console() -def kubeconfig_available(): +def kubeconfig_available() -> bool: """Verify if kubeconfig is available.""" try: config.load_kube_config() @@ -59,7 +60,7 @@ def kubeconfig_available(): return False -def cloud_yaml_exists(): +def cloud_yaml_exists() -> bool: """Check if OpenStack clouds.yaml file exists.""" # Default OpenStack config paths (can vary by environment) default_paths = [ @@ -72,7 +73,7 @@ def cloud_yaml_exists(): return any(p.exists() for p in default_paths) -def openstack_config_available(): +def openstack_config_available() -> bool: """Verify if OpenStack cloud config is available.""" try: os_config = OpenStackConfig() @@ -97,7 +98,11 @@ def openstack_config_available(): return False -def validate_tenant_name(ctx, param, value): +def validate_tenant_name( + ctx: click.Context, + param: click.Parameter, + value: str, +) -> str: """Validate tenant name.""" pattern = r"^[a-zA-Z0-9]+-[a-zA-Z0-9]+-[a-zA-Z0-9]+$" if not re.match(pattern, value): @@ -107,7 +112,7 @@ def validate_tenant_name(ctx, param, value): return value -def init_options(func): +def init_options(func: Callable[..., Any]) -> Callable[..., Any]: """Login options for the CLI login command.""" func = click.option( "--tenant-name", @@ -193,7 +198,7 @@ def init_options(func): return func -def select_provider(): +def select_provider() -> Any: """Select provider.""" choices = [ ("EUMETSAT", "EUMETSAT"), @@ -205,8 +210,8 @@ def select_provider(): # Use the widget's own default key bindings kb = radio_list.control.key_bindings - @kb.add("enter") - def _(event): + @kb.add("enter") # type: ignore[misc] + def _(event: KeyPressEvent) -> None: index = radio_list._selected_index selected_value = radio_list.values[index][ 1 @@ -214,9 +219,9 @@ def _(event): event.app.exit(result=selected_value) # Add quit keys as well - @kb.add("c-c") - @kb.add("c-q") - def _(event): + @kb.add("c-c") # type: ignore[misc] + @kb.add("c-q") # type: ignore[misc] + def _(event: KeyPressEvent) -> None: event.app.exit(None) root_container = Box(Frame(radio_list, title="Select Federee"), padding=1) @@ -241,161 +246,172 @@ def _(event): def check_and_generate_ssh_keys( ssh_public_key_path: Optional[str], ssh_private_key_path: Optional[str], - resolved_profile: str -): - """Check for SSH keys, prompt to generate if missing""" - if not ssh_private_key_path: - ssh_private_key_path = ewc_hub_config.EWC_CLI_HUB_SSH_REPO_PATH / f"{resolved_profile}_id_rsa" - - if not ssh_public_key_path: - ssh_public_key_path = ewc_hub_config.EWC_CLI_HUB_SSH_REPO_PATH / f"{resolved_profile}_id_rsa.pub" + resolved_profile: str, +) -> tuple[str, str]: + """ + Ensure SSH keys exist and match, or generate them if missing. + """ + manager = SSHKeyManager() + priv, pub = _resolve_default_paths( + ssh_public_key_path, + ssh_private_key_path, + resolved_profile, + ) - private_exists = Path(ssh_private_key_path).exists() - public_exists = Path(ssh_public_key_path).exists() + private_exists = priv.exists() + public_exists = pub.exists() if private_exists and public_exists: - # case 1: both exist - console.print( - "Using the following path for the SSH keypair:" - f"\nSSH public key path: {ssh_public_key_path}" - f"\nSSH private key path: {ssh_private_key_path}\n" + return _handle_existing_keys(manager, priv, pub) + + if not private_exists and not public_exists: + return _handle_missing_keys(manager, priv, pub, resolved_profile) + + return _handle_partial_keys(priv, pub) + + +def _handle_existing_keys( + manager: SSHKeyManager, priv: Path, pub: Path +) -> tuple[str, str]: + """ + Validate an existing SSH keypair and return their paths. + """ + console.print( + Panel( + f"Using existing SSH keypair:\n" + f"[green]Public:[/green] {pub}\n" + f"[green]Private:[/green] {priv}", + title="SSH Keys Found", + style="cyan", ) - console.print("SSH key pair exists, checking consistency...") + ) - is_matching = check_ssh_keys_match( - ssh_private_key_path=ssh_private_key_path, - ssh_public_key_path=ssh_public_key_path + console.print("Checking SSH keypair consistency...") + + try: + manager.keys_match(priv, pub) + except SSHKeyError as exc: + raise ClickException( + f"SSH keys are invalid or mismatched:\n{exc}\n" + "Provide a correct keypair or let `ewc login` generate one." ) - if not is_matching: - raise ClickException( - "SSH keys provided are not a correct keypair:" - f"\nSSH public key path: {ssh_public_key_path}" - f"\nSSH private key path: {ssh_private_key_path}" - "\nMake sure either you pass correct SSH keypair in the EWC login command through the following flags `--ssh-private-key-path` and `--ssh-public-key-path`" - "or let the `ewc login` command create them for you. Exiting." - ) - else: - click.secho("SSH private and public keys are matching! Continuing...", fg="green") - - return ssh_private_key_path, ssh_public_key_path + console.print("[green]SSH keypair is valid. Continuing...[/green]") + return str(priv), str(pub) - elif not private_exists and not public_exists: - # case 2: neither exists - console.print( - "SSH keypair is missing:" - f"\nSSH public key path: {ssh_public_key_path}" - f"\nSSH private key path: {ssh_private_key_path}\n" + +def _handle_missing_keys( + manager: SSHKeyManager, priv: Path, pub: Path, resolved_profile: str +) -> tuple[str, str]: + """ + Handle the case where no SSH keys exist. + """ + console.print( + Panel( + f"SSH keypair not found:\nPublic: {pub}\nPrivate: {priv}", + title="SSH Keys Missing", + style="yellow", ) + ) - if click.confirm("Do you want to generate a new SSH key pair?", default=False): - ssh_custom_private_key_path, ssh_custom_public_key_path = generate_ssh_keypair( - resolved_profile=resolved_profile - ) - return ssh_custom_private_key_path, ssh_custom_public_key_path - else: - raise ClickException( - "SSH key generation skipped but SSH keys are mandatory to deploy VMs or hub items." - " Make sure either you pass SSH keys in the EWC login command through the following flags `--ssh-private-key-path` and `--ssh-public-key-path`" - "or let the `ewc login` command create them for you. Exiting." - ) + if click.confirm("Generate a new SSH keypair?", default=False): + new_priv, new_pub = manager.generate_keypair(resolved_profile) + return str(new_priv), str(new_pub) + raise ClickException( + "SSH keys are required. Provide them via:\n" + " --ssh-private-key-path\n" + " --ssh-public-key-path\n" + "or allow `ewc login` to generate them." + ) + + +def _handle_partial_keys(priv: Path, pub: Path) -> NoReturn: + """ + Raise an error when only one of the SSH keys exists. + """ + if priv.exists() and not pub.exists(): + missing = "public" + missing_path = pub else: - # case 3: exactly one exists - if private_exists and not public_exists: - key_exists = "public" - key_path = ssh_public_key_path + missing = "private" + missing_path = priv - if not private_exists and public_exists: - key_exists = "private" - key_path = ssh_private_key_path + raise ClickException( + f"SSH {missing} key is missing at: {missing_path}\n" + "Provide a complete keypair or let `ewc login` generate one." + ) - raise ClickException( - f"SSH {key_exists} key is missing at: {key_path}." - " Make sure the keypair is passed!" - " You can pass SSH keys in the EWC login command through the following flags `--ssh-private-key-path` or `--ssh-public-key-path`" - "or let the `ewc login` command create them for you. Exiting." + +def _resolve_default_paths( + ssh_public_key_path: Optional[str], + ssh_private_key_path: Optional[str], + resolved_profile: str, +) -> tuple[Path, Path]: + """ + Resolve SSH key paths, falling back to profile-based defaults. + """ + if not ssh_private_key_path: + ssh_private_key_path = str( + ewc_hub_config.EWC_CLI_HUB_SSH_REPO_PATH / f"{resolved_profile}_id_rsa" + ) + + if not ssh_public_key_path: + ssh_public_key_path = str( + ewc_hub_config.EWC_CLI_HUB_SSH_REPO_PATH / f"{resolved_profile}_id_rsa.pub" ) + return ( + Path(ssh_private_key_path).expanduser(), + Path(ssh_public_key_path).expanduser(), + ) + + +def init_command(data: LoginInput) -> None: + """ + Initialize an EWC CLI login session. -def init_command( - application_credential_id: str, - application_credential_secret: str, - ssh_public_key_path: str, - ssh_private_key_path: str, - tenant_name: str, - federee: str, - profile: str = None - # token: str, -): - """EWC CLI Login.""" - if not federee: - # If --federee is not passed, ask interactively + This orchestrates: + - federee selection + - profile resolution + - SSH key validation or generation + - OpenStack credential resolution + - persistence of the login profile + """ + # 1. Resolve federee + if not data.federee: federee = select_provider() if not federee: console.print("No selection made. Exiting.") return + else: + federee = data.federee console.print(f"Considering federee: {federee}") - resolved_profile = _resolve_profile(profile, federee, tenant_name) - - profiles_file_path = ewc_hub_config.EWC_CLI_PROFILES_PATH - cfg = ConfigParser() - cfg.read(profiles_file_path) + # 2. Resolve profile name + store = ProfileStore() + resolved_profile = store.resolve_name(data.profile, federee, data.tenant_name) - if not os.path.exists(profiles_file_path) or not cfg.sections(): - pass - else: - # Check only when the profile path exist - if resolved_profile and resolved_profile in cfg: - click.secho( - f"❌ Profile '{resolved_profile}' already exists in {ewc_hub_config.EWC_CLI_PROFILES_PATH}", - fg="red", - bold=True, - ) - click.secho( - "Use a different profile name or delete the existing profile first.", - fg="yellow", - ) - raise click.Abort() + # 3. Ensure profile does not already exist + _ensure_profile_not_exists(store, resolved_profile) - ssh_private_key_path_to_save, ssh_public_key_path_to_save = check_and_generate_ssh_keys( - ssh_public_key_path=ssh_public_key_path, - ssh_private_key_path=ssh_private_key_path, + # 4. Resolve SSH keys + priv_path, pub_path = _resolve_ssh_keys( + ssh_public_key_path=data.ssh_public_key_path, + ssh_private_key_path=data.ssh_private_key_path, resolved_profile=resolved_profile, ) - - if openstack_config_available(): - console.print( - "🔑 [bold green]Openstack cloud.yaml found at ~/.config/openstack/clouds.yaml[/bold green]" - " – skipping Openstack ID and secret requirements." + # 5. Resolve OpenStack credentials + application_credential_id, application_credential_secret = ( + _resolve_openstack_credentials( + data.application_credential_id, + data.application_credential_secret, ) - application_credential_id = "" - application_credential_secret = "" - - elif not application_credential_id or not application_credential_secret: - if not application_credential_id: - # Handle OpenStack credential ID - application_credential_id = ( - application_credential_id - or os.getenv("OS_APPLICATION_CREDENTIAL_ID") - or click.prompt( - "Enter OpenStack Application Credential ID", hide_input=True - ) - ) - - if not application_credential_secret: - # Handle OpenStack credential secret - application_credential_secret = ( - application_credential_secret - or os.getenv("OS_APPLICATION_CREDENTIAL_SECRET") - or click.prompt( - "Enter OpenStack Application Credential Secret", hide_input=True - ) - ) + ) + # TODO: token not available in the profile # if kubeconfig_available(): # click.echo("🔑 kubeconfig found – skipping token requirement.") # token = None @@ -410,30 +426,104 @@ def init_command( # if token == "": # token = None - # - save_default_login_profile( + # 6. Build the Pydantic model + profile_data = ProfileData( federee=federee, - tenant_name=tenant_name, - ssh_private_key_path_to_save=ssh_private_key_path_to_save, - ssh_public_key_path_to_save=ssh_public_key_path_to_save, - # token=token, + tenant_name=data.tenant_name, + profile=resolved_profile, + ssh_private_key_path_to_save=str(priv_path), + ssh_public_key_path_to_save=str(pub_path), + # token=None, application_credential_id=application_credential_id, application_credential_secret=application_credential_secret, ) - # Save config - save_cli_profile( - federee=federee, - tenant_name=tenant_name, - ssh_private_key_path_to_save=ssh_private_key_path_to_save, - ssh_public_key_path_to_save=ssh_public_key_path_to_save, - profile=profile, - # token=token, - application_credential_id=application_credential_id, - application_credential_secret=application_credential_secret, - ) + # 7. Save profile + store.save_default(profile_data) + store.save(profile_data) console.print( - f"✅ Profile '[bold cyan]{resolved_profile}[/bold cyan]' saved " - f"in the following file {ewc_hub_config.EWC_CLI_PROFILES_PATH}" + f"✅ Profile '[bold cyan]{resolved_profile}[/bold cyan]' saved in {store.path}" ) + + +def _resolve_ssh_keys( + ssh_public_key_path: Optional[str], + ssh_private_key_path: Optional[str], + resolved_profile: str, +) -> tuple[str, str]: + """ + Resolve SSH keypair for the login flow. + + Delegates all SSH key validation and generation logic to + `check_and_generate_ssh_keys`, which uses SSHKeyManager internally. + + Returns + ------- + tuple[str, str] + The resolved private and public SSH key paths. + """ + return check_and_generate_ssh_keys( + ssh_public_key_path=ssh_public_key_path, + ssh_private_key_path=ssh_private_key_path, + resolved_profile=resolved_profile, + ) + + +def _resolve_openstack_credentials( + application_credential_id: Optional[str] = None, + application_credential_secret: Optional[str] = None, +) -> tuple[str, str]: + """ + Resolve OpenStack application credentials. + + If a valid OpenStack clouds.yaml is detected, credentials are skipped. + Otherwise, missing values are retrieved from environment variables + or prompted interactively. + + Returns + ------- + tuple[str, str] + The resolved (ID, secret) pair. + """ + if openstack_config_available(): + console.print( + "🔑 [bold green]Openstack cloud.yaml found[/bold green] – skipping credentials." + ) + return "", "" + + if not application_credential_id: + application_credential_id = os.getenv( + "OS_APPLICATION_CREDENTIAL_ID" + ) or click.prompt("Enter OpenStack Application Credential ID", hide_input=True) + + if not application_credential_secret: + application_credential_secret = os.getenv( + "OS_APPLICATION_CREDENTIAL_SECRET" + ) or click.prompt( + "Enter OpenStack Application Credential Secret", hide_input=True + ) + + return application_credential_id, application_credential_secret + + +def _ensure_profile_not_exists(store: ProfileStore, resolved_profile: str) -> None: + """ + Ensure that a profile with the given name does not already exist. + + Raises + ------ + click.Abort + If the profile already exists. + """ + if store.exists(resolved_profile): + click.secho( + f"❌ Profile '{resolved_profile}' already exists in {store.path}", + fg="red", + bold=True, + ) + click.secho( + "Use a different profile name or delete the existing profile first.", + fg="yellow", + ) + raise click.Abort() diff --git a/ewccli/configuration.py b/ewccli/configuration.py index c71758d..ee60dec 100644 --- a/ewccli/configuration.py +++ b/ewccli/configuration.py @@ -8,9 +8,29 @@ import os from pathlib import Path +from typing import Optional +from pydantic import BaseModel + from ewccli.enums import Federee, FedereeDNSMapping +class LoginInput(BaseModel): # type: ignore[misc] + """ + Raw login input provided by the user before resolution. + """ + + tenant_name: str + federee: str + + application_credential_id: Optional[str] = None + application_credential_secret: Optional[str] = None + + ssh_public_key_path: Optional[str] = None + ssh_private_key_path: Optional[str] = None + + profile: Optional[str] = None + + class EWCCLIConfiguration: """EWC CLI global configuration.""" @@ -57,7 +77,6 @@ class EWCCLIConfiguration: "Rocky-9": "cloud-user", "Rocky-9-GPU": "cloud-user", "Ubuntu 22.04 NVIDIA_AI": "eouser", - } EWC_CLI_AUTH_URL_MAP = { @@ -77,13 +96,12 @@ class EWCCLIConfiguration: } # GPU images - EWC_CLI_GPU_IMAGES = [v for _,v in EWC_CLI_GPU_IMAGES_SITE_MAP.items()] - + EWC_CLI_GPU_IMAGES = [v for _, v in EWC_CLI_GPU_IMAGES_SITE_MAP.items()] # Openstack value of the GPU images EWC_CLI_OS_GPU_IMAGES_SITE_MAP = { Federee.ECMWF.value: "Rocky-9.6-GPU", # This can be find after normalization - Federee.EUMETSAT.value: "Ubuntu 22.04 NVIDIA_AI", # ( usually fixed) + Federee.EUMETSAT.value: "Ubuntu 22.04 NVIDIA_AI", # ( usually fixed) } # Flavors @@ -118,7 +136,7 @@ class EWCCLIConfiguration: } # Network - + DEFAULT_NETWORK_MAP = { Federee.ECMWF.value: "private", Federee.EUMETSAT.value: "private", diff --git a/ewccli/ewccli.py b/ewccli/ewccli.py index dc17b3b..c91f5ad 100644 --- a/ewccli/ewccli.py +++ b/ewccli/ewccli.py @@ -27,43 +27,45 @@ # from ewccli.commands.s3_command import ewc_s3_command from ewccli.configuration import config as ewc_hub_config +from ewccli.configuration import LoginInput -CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) +CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} -@click.group(context_settings=CONTEXT_SETTINGS) -def cli(): +@click.group(context_settings=CONTEXT_SETTINGS) # type: ignore[misc] +def cli() -> None: """European Weather Cloud (EWC) CLI.""" pass -@cli.command(name="login", help="Initialize configuration for EWC CLI.") +@cli.command(name="login", help="Initialize configuration for EWC CLI.") # type: ignore[misc] @init_options -def init( +def init( # noqa: CFQ002 + tenant_name: str, + federee: str, application_credential_id: str, application_credential_secret: str, ssh_public_key_path: str, ssh_private_key_path: str, - tenant_name: str, - federee: str, profile: Optional[str] = None, # token: str, -): +) -> None: """Login command.""" - init_command( + data = LoginInput( + tenant_name=tenant_name, + federee=federee, application_credential_id=application_credential_id, application_credential_secret=application_credential_secret, ssh_public_key_path=ssh_public_key_path, ssh_private_key_path=ssh_private_key_path, - tenant_name=tenant_name, - federee=federee, profile=profile, - # token=token, ) + init_command(data) + -def get_version(): +def get_version() -> str: """ Return the version of the installed package. @@ -86,8 +88,8 @@ def get_version(): return __version__ -@cli.command(name="version", help="Show EWC CLI version.") -def version_cmd(): +@cli.command(name="version", help="Show EWC CLI version.") # type: ignore[misc] +def version_cmd() -> None: """ Display the version of the EWC CLI. diff --git a/ewccli/logger.py b/ewccli/logger.py index 105fd6f..6a4960d 100644 --- a/ewccli/logger.py +++ b/ewccli/logger.py @@ -9,6 +9,9 @@ """EWC CLI Logger.""" import logging + +from logging import Logger, LogRecord +from typing import Optional from datetime import datetime, timezone from rich.console import Console from rich.logging import RichHandler @@ -22,7 +25,7 @@ class UTCFormatter(logging.Formatter): """UTCFormatter for logging.""" - def format_time(self, record, datefmt=None): + def format_time(self, record: LogRecord, datefmt: Optional[str] = None) -> str: """Format time.""" dt = datetime.fromtimestamp(record.created, tz=timezone.utc) if datefmt: @@ -33,7 +36,7 @@ def format_time(self, record, datefmt=None): return s -def get_logger(name=None): +def get_logger(name: Optional[str] = None) -> Logger: """Create logger""" logger = logging.getLogger(name) diff --git a/ewccli/profile.py b/ewccli/profile.py new file mode 100644 index 0000000..507a8f2 --- /dev/null +++ b/ewccli/profile.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python +# +# Package Name: ewccli +# License: GPL-3.0-or-later +# Copyright (c) 2026 EUMETSAT, ECMWF for European Weather Cloud +# See the LICENSE file for more details + + +"""Profile.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional, Dict + +from configparser import ConfigParser, SectionProxy +from pydantic import BaseModel, Field, ValidationError + +import rich_click as click + +from ewccli.configuration import config as ewc_hub_config +from ewccli.logger import get_logger +from ewccli.enums import Federee + +_LOGGER = get_logger(__name__) + + +# --------------------------------------------------------------------------- +# Pydantic Profile Model +# --------------------------------------------------------------------------- + + +class ProfileData(BaseModel): # type: ignore[misc] + """ + Structured profile data validated by Pydantic. + """ + + federee: Federee + tenant_name: str + ssh_private_key_path: str = Field(..., alias="ssh_private_key_path_to_save") + ssh_public_key_path: str = Field(..., alias="ssh_public_key_path_to_save") + + profile: Optional[str] = None + token: Optional[str] = None + application_credential_id: Optional[str] = None + application_credential_secret: Optional[str] = None + region: Optional[str] = None + + class Config: + validate_by_name = True + + # ------------------------------------------------------------------ + # Conversion helpers + # ------------------------------------------------------------------ + + @classmethod + def from_section(cls, name: str, section: SectionProxy) -> "ProfileData": + """ + Build a ProfileData instance from a ConfigParser section. + """ + try: + return cls( + profile=name, + federee=section.get("federee"), + tenant_name=section.get("tenant_name"), + ssh_public_key_path_to_save=section.get("ssh_public_key_path"), + ssh_private_key_path_to_save=section.get("ssh_private_key_path"), + region=section.get("region"), + token=section.get("token"), + application_credential_id=section.get("application_credential_id"), + application_credential_secret=section.get( + "application_credential_secret" + ), + ) + except ValidationError as exc: + raise click.ClickException(f"Invalid profile '{name}': {exc}") from exc + + def to_section(self) -> Dict[str, str]: + """ + Convert this profile into a dict suitable for ConfigParser. + """ + data = { + "federee": self.federee.value, + "tenant_name": self.tenant_name, + "ssh_public_key_path": self.ssh_public_key_path, + "ssh_private_key_path": self.ssh_private_key_path, + } + + if self.region: + data["region"] = self.region + if self.token: + data["token"] = self.token + if self.application_credential_id: + data["application_credential_id"] = self.application_credential_id + if self.application_credential_secret: + data["application_credential_secret"] = self.application_credential_secret + + return data + + +# --------------------------------------------------------------------------- +# Profile Store +# --------------------------------------------------------------------------- + + +class ProfileStore: + """ + Manage reading/writing EWC CLI profiles from the profiles file. + """ + + def __init__(self, path: Path = ewc_hub_config.EWC_CLI_PROFILES_PATH): + self.path = path + self.cfg = self._load_or_init() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _load_or_init(self) -> ConfigParser: + """ + Load the profiles file if it exists, otherwise return an empty ConfigParser. + """ + cfg = ConfigParser() + if self.path.exists(): + cfg.read(self.path) + return cfg + + def _save(self) -> None: + """ + Persist the profiles file to disk. + """ + self.path.parent.mkdir(parents=True, exist_ok=True) + with open(self.path, "w") as f: + self.cfg.write(f) + + # ------------------------------------------------------------------ + # Public methods + # ------------------------------------------------------------------ + + def list_profiles(self) -> list[str]: + """Return all profile names.""" + return self.cfg.sections() + + def exists(self, name: str) -> bool: + """Return True if a profile exists.""" + return name in self.cfg + + def resolve_name( + self, + profile: Optional[str], + federee: Optional[str], + tenant: Optional[str], + ) -> str: + """ + Resolve the profile name from explicit input or federee+tenant. + """ + if profile: + return profile + + if not federee or not tenant: + click.secho( + "❌ Either 'profile' must be provided or both 'federee' and 'tenant_name'.", + fg="red", + bold=True, + ) + raise click.Abort() + + return f"{federee}-{tenant}" + + # ------------------------------------------------------------------ + # Load + # ------------------------------------------------------------------ + + def load(self, name: str) -> ProfileData: + """ + Load a profile by name and return a validated ProfileData instance. + """ + if name not in self.cfg: + self._error_missing_profile(name) + + section = self.cfg[name] + return ProfileData.from_section(name, section) + + def _error_missing_profile(self, name: str) -> None: + click.secho(f"❌ Profile '{name}' not found.", fg="red", bold=True) + click.secho(f"Searched in: {self.path}", fg="cyan") + + profiles = self.list_profiles() + if profiles: + click.secho("â„šī¸ Available profiles:", fg="yellow") + for p in profiles: + click.secho(f" â€ĸ {p}", fg="green") + + raise click.Abort() + + # ------------------------------------------------------------------ + # Save + # ------------------------------------------------------------------ + + def save(self, data: ProfileData) -> None: + """ + Save a validated profile. Fails if the profile already exists. + """ + name = data.profile or f"{data.federee.value}-{data.tenant_name}" + + if self.exists(name): + click.secho( + f"❌ Profile '{name}' already exists in {self.path}", + fg="red", + bold=True, + ) + click.secho( + "Use a different profile name or delete the existing profile first.", + fg="yellow", + ) + raise click.Abort() + + self.cfg[name] = data.to_section() + self._save() + + # ------------------------------------------------------------------ + # Save default profile + # ------------------------------------------------------------------ + + def save_default(self, data: ProfileData) -> None: + """ + Save the default login profile only if it does not exist. + """ + default_name = ewc_hub_config.EWC_CLI_DEFAULT_PROFILE_NAME + + if self.exists(default_name): + return + + # Force the profile name + data.profile = default_name + + self.cfg[default_name] = data.to_section() + self._save() diff --git a/ewccli/ssh_keys_manager.py b/ewccli/ssh_keys_manager.py new file mode 100644 index 0000000..26ced13 --- /dev/null +++ b/ewccli/ssh_keys_manager.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python +# +# Package Name: ewccli +# License: GPL-3.0-or-later +# Copyright (c) 2026 EUMETSAT, ECMWF for European Weather Cloud +# See the LICENSE file for more details + + +""" +SSH Key Management for EWC CLI. + +Provides loading, decoding, verification, saving, and generation of SSH keys. +""" + +from __future__ import annotations + +import os +import base64 +from pathlib import Path +from typing import Optional, Tuple + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.backends import default_backend + +from ewccli.configuration import config as ewc_hub_config +from ewccli.logger import get_logger + +_LOGGER = get_logger(__name__) + + +class SSHKeyError(Exception): + """Custom exception for SSH key operations.""" + + +class SSHKeyManager: + """ + Manage SSH key loading, decoding, verification, saving, and generation. + """ + + def __init__(self, repo_path: Path = ewc_hub_config.EWC_CLI_HUB_SSH_REPO_PATH): + self.repo_path = Path(repo_path) + + # ------------------------------------------------------------------ + # Decoding + # ------------------------------------------------------------------ + + def load_private_encoded(self, encoded: Optional[str]) -> str: + """ + Decode a base64‑encoded private key. + + Raises + ------ + SSHKeyError + If the key is missing or invalid. + """ + if encoded is None: + raise SSHKeyError("Missing encoded private key") + + try: + return base64.b64decode(encoded).decode("utf-8") + except Exception as exc: + raise SSHKeyError(f"Failed to decode private key: {exc}") from exc + + def load_public_encoded(self, encoded: Optional[str]) -> str: + """ + Decode a base64‑encoded public key. + + Raises + ------ + SSHKeyError + If the key is missing or invalid. + """ + if encoded is None: + raise SSHKeyError("Missing encoded public key") + + try: + return base64.b64decode(encoded).decode("utf-8") + except Exception as exc: + raise SSHKeyError(f"Failed to decode public key: {exc}") from exc + + # ------------------------------------------------------------------ + # Verification + # ------------------------------------------------------------------ + + def verify_private(self, private_key: str) -> None: + """ + Verify that a private key is valid. + + Raises + ------ + SSHKeyError + If the key is invalid or unsupported. + """ + try: + serialization.load_pem_private_key( + private_key.encode("utf-8"), + password=None, + backend=default_backend(), + ) + except Exception as exc: + raise SSHKeyError(f"Invalid private key: {exc}") from exc + + def keys_match(self, private_path: Path, public_path: Path) -> bool: + """ + Check whether a private key corresponds to a given public key. + + Returns + ------- + bool + True if the keys match. + + Raises + ------ + SSHKeyError + If files are missing or formats are invalid. + """ + private_path = Path(private_path).expanduser() + public_path = Path(public_path).expanduser() + + if not private_path.is_file(): + raise SSHKeyError(f"Private key file does not exist: {private_path}") + if not public_path.is_file(): + raise SSHKeyError(f"Public key file does not exist: {public_path}") + + private_data = private_path.read_bytes() + + try: + private_key = serialization.load_pem_private_key( + private_data, password=None + ) + except ValueError: + try: + private_key = serialization.load_ssh_private_key( + private_data, password=None + ) + except ValueError as exc: + raise SSHKeyError("Unsupported or invalid private key format") from exc + + derived_public = private_key.public_key().public_bytes( + encoding=serialization.Encoding.OpenSSH, + format=serialization.PublicFormat.OpenSSH, + ) + + parts = public_path.read_text().strip().split() + if len(parts) < 2: + raise SSHKeyError(f"Invalid public key format: {public_path}") + + provided_public = " ".join(parts[:2]).encode() + + return bool(derived_public == provided_public) + + def keys_exist(self, ssh_public_key_path: Path, ssh_private_key_path: Path) -> None: + """ + Ensure that both SSH key files exist. + + Raises + ------ + SSHKeyError + If one or both files are missing. + """ + public_path = Path(ssh_public_key_path) + private_path = Path(ssh_private_key_path) + + missing = [] + + if not private_path.is_file(): + missing.append( + f"🔒 [bold red]Missing Private Key:[/bold red] {private_path}" + ) + + if not public_path.is_file(): + missing.append(f"🔓 [bold red]Missing Public Key:[/bold red] {public_path}") + + missing_msg = ( + "\n".join(missing) + + "\n\n" + + "[bold yellow]Tip:[/bold yellow] You can run ewc login and create them.\n" + + "[bold yellow]Tip:[/bold yellow] You can specify custom paths with:\n" + + '[green]export EWC_CLI_SSH_PRIVATE_KEY_PATH="/path/to/id_rsa"[/green]\n' + + '[green]export EWC_CLI_SSH_PUBLIC_KEY_PATH="/path/to/id_rsa.pub"[/green]' + ) + + if missing: + raise SSHKeyError(missing_msg) + + # ------------------------------------------------------------------ + # Saving + # ------------------------------------------------------------------ + + def save_key(self, key: str, path: Path) -> None: + """ + Save a key to disk with secure permissions. + """ + path = Path(path).expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + + path.write_text(key) + os.chmod(path, 0o600) + + _LOGGER.debug(f"SSH key saved to {path} with 0600 permissions.") + + def save_encoded_keys( + self, + public_path: Path, + private_path: Path, + public_encoded: Optional[str], + private_encoded: Optional[str], + ) -> Tuple[bool, bool]: + """ + Save base64‑encoded SSH keys to disk. + + Returns + ------- + (bool, bool) + Tuple indicating whether public and private keys were written. + """ + public_written = False + private_written = False + + # PUBLIC + if public_encoded: + public_key = self.load_public_encoded(public_encoded) + self.save_key(public_key, public_path) + public_written = True + + # PRIVATE + if private_encoded: + private_key = self.load_private_encoded(private_encoded) + self.verify_private(private_key) + self.save_key(private_key, private_path) + private_written = True + + return public_written, private_written + + # ------------------------------------------------------------------ + # Generation + # ------------------------------------------------------------------ + + def generate_keypair(self, profile_name: str) -> Tuple[Path, Path]: + """ + Generate an RSA SSH keypair and save it under the repo path. + + Returns + ------- + (Path, Path) + Paths to the private and public key files. + """ + self.repo_path.mkdir(parents=True, exist_ok=True) + + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend(), + ) + + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + + public_ssh = private_key.public_key().public_bytes( + encoding=serialization.Encoding.OpenSSH, + format=serialization.PublicFormat.OpenSSH, + ) + + private_path = self.repo_path / f"{profile_name}_id_rsa" + public_path = self.repo_path / f"{profile_name}_id_rsa.pub" + + private_path.write_bytes(private_pem) + os.chmod(private_path, 0o600) + + public_path.write_bytes(public_ssh) + os.chmod(public_path, 0o644) + + _LOGGER.info(f"SSH keypair generated at {private_path} and {public_path}") + + return private_path, public_path diff --git a/ewccli/tests/ewccli_backends_openstack_test.py b/ewccli/tests/ewccli_backends_openstack_test.py index 7864bd4..18b71f5 100644 --- a/ewccli/tests/ewccli_backends_openstack_test.py +++ b/ewccli/tests/ewccli_backends_openstack_test.py @@ -7,7 +7,6 @@ import pytest -from pathlib import Path from types import SimpleNamespace from ewccli.backends.openstack.backend_ostack import OpenstackBackend @@ -94,4 +93,4 @@ def test_ssh_key_comment_ignored(tmp_path, backend): keypair=keypair, ) - assert result is True \ No newline at end of file + assert result is True diff --git a/ewccli/tests/ewccli_cli_hub_deploy_test.py b/ewccli/tests/ewccli_cli_hub_deploy_test.py index f86f70a..62e4175 100644 --- a/ewccli/tests/ewccli_cli_hub_deploy_test.py +++ b/ewccli/tests/ewccli_cli_hub_deploy_test.py @@ -7,10 +7,11 @@ import pytest from click.testing import CliRunner -from unittest.mock import patch, MagicMock +from unittest.mock import patch from ewccli.ewccli import cli + @pytest.fixture def runner(): return CliRunner() @@ -59,7 +60,7 @@ def mock_profile_loader(tmp_path, valid_private_key_pem, valid_public_key_openss pub_key.write_text(valid_public_key_openssh) priv_key.write_text(valid_private_key_pem) - with patch("ewccli.commands.hub.hub_command.load_cli_profile") as mock_load: + with patch("ewccli.commands.hub.hub_command.ProfileStore.load") as mock_load: mock_load.return_value = { "profile": "test-profile", "auth_url": "http://fake-auth-url", @@ -78,9 +79,10 @@ def mock_profile_loader(tmp_path, valid_private_key_pem, valid_public_key_openss # ----------------------------- @pytest.fixture(autouse=True) def mock_ctx_obj(): - with patch("ewccli.commands.hub.hub_command.categorize_item_inputs") as m1, \ - patch("ewccli.commands.hub.hub_command.check_missing_required_inputs") as m2: - + with ( + patch("ewccli.commands.hub.hub_command.categorize_item_inputs") as m1, + patch("ewccli.commands.hub.hub_command.check_missing_required_inputs") as m2, + ): m1.return_value = ([], []) m2.return_value = [] @@ -104,13 +106,7 @@ def test_deploy_dry_run_minimal(runner): result = runner.invoke( cli, ["hub", "deploy", "ssh-bastion-flavour", "--dry-run"], - obj={ - "items": { - "ssh-bastion-flavour": { - "cli": {"inputs": []} - } - } - }, + obj={"items": {"ssh-bastion-flavour": {"cli": {"inputs": []}}}}, ) print(result.output) print(result.exception) @@ -118,7 +114,9 @@ def test_deploy_dry_run_minimal(runner): assert result.exit_code == 0 -def test_deploy_with_ssh_paths(runner, tmp_path, valid_private_key_pem, valid_public_key_openssh): +def test_deploy_with_ssh_paths( + runner, tmp_path, valid_private_key_pem, valid_public_key_openssh +): pub_key = tmp_path / "id_rsa.pub" priv_key = tmp_path / "id_rsa" @@ -137,19 +135,15 @@ def test_deploy_with_ssh_paths(runner, tmp_path, valid_private_key_pem, valid_pu str(priv_key), "--dry-run", ], - obj={ - "items": { - "ssh-bastion-flavour": { - "cli": {"inputs": []} - } - } - }, + obj={"items": {"ssh-bastion-flavour": {"cli": {"inputs": []}}}}, ) assert result.exit_code == 0 -def test_deploy_with_env_vars(runner, tmp_path, valid_private_key_pem, valid_public_key_openssh): +def test_deploy_with_env_vars( + runner, tmp_path, valid_private_key_pem, valid_public_key_openssh +): pub_key = tmp_path / "id_rsa.pub" priv_key = tmp_path / "id_rsa" @@ -163,13 +157,7 @@ def test_deploy_with_env_vars(runner, tmp_path, valid_private_key_pem, valid_pub "EWC_CLI_SSH_PUBLIC_KEY_PATH": str(pub_key), "EWC_CLI_SSH_PRIVATE_KEY_PATH": str(priv_key), }, - obj={ - "items": { - "ssh-bastion-flavour": { - "cli": {"inputs": []} - } - } - }, + obj={"items": {"ssh-bastion-flavour": {"cli": {"inputs": []}}}}, ) assert result.exit_code == 0 diff --git a/ewccli/tests/ewccli_cli_infra_create_test.py b/ewccli/tests/ewccli_cli_infra_create_test.py new file mode 100644 index 0000000..a85158b --- /dev/null +++ b/ewccli/tests/ewccli_cli_infra_create_test.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python +# +# Package Name: ewccli +# License: GPL-3.0-or-later +# Copyright (c) 2026 EUMETSAT, ECMWF for European Weather Cloud +# See the LICENSE file for more details + + +from __future__ import annotations + +import pytest +from click.testing import CliRunner +from unittest.mock import patch, MagicMock + +from ewccli.ewccli import cli + +@pytest.fixture +def runner(): + return CliRunner() + + +# ----------------------------- +# VALID SSH KEY FIXTURES +# ----------------------------- +@pytest.fixture +def valid_private_key_pem() -> str: + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.hazmat.primitives import serialization + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + return pem.decode("utf-8") + + +@pytest.fixture +def valid_public_key_openssh(valid_private_key_pem: str) -> str: + from cryptography.hazmat.primitives import serialization + + private_key = serialization.load_pem_private_key( + valid_private_key_pem.encode("utf-8"), password=None + ) + pub = private_key.public_key() + return pub.public_bytes( + encoding=serialization.Encoding.OpenSSH, + format=serialization.PublicFormat.OpenSSH, + ).decode("utf-8") + + +# ----------------------------- +# Mock profile loader globally +# ----------------------------- +@pytest.fixture(autouse=True) +def mock_profile_loader(tmp_path, valid_private_key_pem, valid_public_key_openssh): + pub_key = tmp_path / "id_rsa.pub" + priv_key = tmp_path / "id_rsa" + + # ✅ write VALID keys + pub_key.write_text(valid_public_key_openssh) + priv_key.write_text(valid_private_key_pem) + + with patch("ewccli.commands.hub.hub_command.ProfileStore.load") as mock_load: + mock_load.return_value = { + "profile": "test-profile", + "auth_url": "http://fake-auth-url", + "application_credential_id": "fake-id", + "application_credential_secret": "fake-secret", + "tenant_name": "test-tenant", + "federee": "test-federee", + "ssh_public_key_path": str(pub_key), + "ssh_private_key_path": str(priv_key), + } + yield mock_load + +# ----------------------------- +# Mock ctx.obj (CRITICAL) +# ----------------------------- +@pytest.fixture +def mock_ctx(tmp_path, valid_private_key_pem, valid_public_key_openssh): + pub = tmp_path / "id_rsa.pub" + priv = tmp_path / "id_rsa" + + pub.write_text(valid_public_key_openssh) + priv.write_text(valid_private_key_pem) + + ctx = MagicMock() + ctx.cli_profile = { + "federee": "test-fed", + "ssh_public_key_path": str(pub), + "ssh_private_key_path": str(priv), + } + + ctx.openstack_backend = MagicMock() + ctx.openstack_backend.connect.return_value = MagicMock() + + return ctx + + +# ----------------------------- +# Patch decorators to pass ctx +# ----------------------------- +@pytest.fixture(autouse=True) +def patch_infra_decorators(monkeypatch): + monkeypatch.setattr("ewccli.commands.infra_command.infra_context", lambda f: f) + monkeypatch.setattr("ewccli.commands.infra_command.ssh_options", lambda f: f) + monkeypatch.setattr("ewccli.commands.infra_command.ssh_options_encoded", lambda f: f) + monkeypatch.setattr("ewccli.commands.infra_command.openstack_options", lambda f: f) + monkeypatch.setattr("ewccli.commands.infra_command.openstack_optional_options", lambda f: f) + +# ----------------------------- +# Patch create_server_command +# ----------------------------- +@pytest.fixture +def mock_create_server(): + with patch("ewccli.commands.infra_command.create_server_command") as mock: + mock.return_value = ( + 0, + "OK", + { + "internal_ip_machine": "10.0.0.5", + "external_ip_machine": "1.2.3.4", + "normalized_image_name": "ubuntu-22", + }, + ) + yield mock + + +# ----------------------------- +# TESTS +# ----------------------------- +def test_create_help(runner, patch_infra_decorators): + result = runner.invoke(cli, ["infra", "create", "--help"]) + assert result.exit_code == 0 + + +def test_create_dry_run(runner, mock_ctx, mock_create_server): + result = runner.invoke( + cli, + ["infra", "create", "my-server", "--dry-run"], + obj=mock_ctx, + ) + assert result.exit_code == 0 + assert "Dry run enabled" in result.output + + +def test_create_with_ssh_paths( + runner, tmp_path, valid_private_key_pem, valid_public_key_openssh, mock_ctx, mock_create_server +): + pub = tmp_path / "id_rsa.pub" + priv = tmp_path / "id_rsa" + + pub.write_text(valid_public_key_openssh) + priv.write_text(valid_private_key_pem) + + result = runner.invoke( + cli, + [ + "infra", + "create", + "my-server", + "--ssh-public-key-path", + str(pub), + "--ssh-private-key-path", + str(priv), + "--dry-run", + ], + obj=mock_ctx, + ) + + assert result.exit_code == 0 + + +def test_create_openstack_failure(runner, mock_ctx): + mock_ctx.openstack_backend.connect.side_effect = Exception("boom") + + result = runner.invoke( + cli, + ["infra", "create", "my-server"], + obj=mock_ctx, + ) + + assert result.exit_code != 0 + assert "Could not connect to Openstack" in result.output + + +def test_create_missing_username_mapping(runner, mock_ctx, mock_create_server): + # Force missing username + with patch("ewccli.commands.infra_command.ewc_hub_config.EWC_CLI_IMAGES_USER", {}): + result = runner.invoke( + cli, + ["infra", "create", "my-server"], + obj=mock_ctx, + ) + + assert result.exit_code != 0 + assert "username for ubuntu-22 could not be identified" in result.output + + +def test_create_success(runner, mock_ctx, mock_create_server): + result = runner.invoke( + cli, + ["infra", "create", "my-server"], + obj=mock_ctx, + ) + + assert result.exit_code == 0 + assert "Deployment Complete" in result.output + assert "ssh -i" in result.output diff --git a/ewccli/tests/ewccli_commands_common_infra_test.py b/ewccli/tests/ewccli_commands_common_infra_test.py index 2ce8285..c47481a 100644 --- a/ewccli/tests/ewccli_commands_common_infra_test.py +++ b/ewccli/tests/ewccli_commands_common_infra_test.py @@ -6,12 +6,11 @@ # See the LICENSE file for more details -"""Tests for EWC commands common methods.""" +"""Tests for EWC commands common infra methods.""" from unittest.mock import MagicMock from unittest.mock import patch import pytest -from pydantic import BaseModel from datetime import datetime from ewccli.tests.ewccli_base_test import SecurityGroup @@ -19,7 +18,6 @@ from ewccli.enums import Federee from ewccli.configuration import EWCCLIConfiguration as ewc_hub_config -from ewccli.backends.openstack.backend_ostack import OpenstackBackend from ewccli.commands.commons_infra import get_deployed_server_info from ewccli.commands.commons_infra import resolve_image_and_flavor from ewccli.commands.commons_infra import normalize_os_image @@ -29,14 +27,13 @@ from ewccli.commands.commons_infra import post_deploy_server_setup - - # --- Fake OpenstackBackend for testing ------------------------------------- class FakeImage: def __init__(self, name: str): self.name = name self.created_at = datetime.utcnow() + # --- Mock backend completely ------------------------------------------------- class FakeOpenstackBackend: def __init__(self, *args, **kwargs): @@ -64,41 +61,59 @@ def __init__(self, name): else: return None + # --- Fixtures --------------------------------------------------------------- @pytest.fixture def backend(): return FakeOpenstackBackend() + @pytest.fixture def conn(): return MagicMock() # mock OpenStack connection + # --- Tests ----------------------------------------------------------------- def test_resolve_cpu_defaults(conn, backend): - code, msg, result = resolve_image_and_flavor(conn, backend, federee="EUMETSAT", is_gpu=False) + code, msg, result = resolve_image_and_flavor( + conn, backend, federee="EUMETSAT", is_gpu=False + ) assert code == 0 assert result["normalized_image_name"] in ewc_hub_config.EWC_CLI_CPU_IMAGES assert result["flavour_name"] == ewc_hub_config.DEFAULT_CPU_FLAVOURS_MAP["EUMETSAT"] + def test_resolve_eumetsat_gpu_defaults(conn, backend): - code, msg, result = resolve_image_and_flavor(conn, backend, federee="EUMETSAT", is_gpu=True) + code, msg, result = resolve_image_and_flavor( + conn, backend, federee="EUMETSAT", is_gpu=True + ) assert code == 0 - assert result["normalized_image_name"] == ewc_hub_config.EWC_CLI_OS_GPU_IMAGES_SITE_MAP["EUMETSAT"] + assert ( + result["normalized_image_name"] + == ewc_hub_config.EWC_CLI_OS_GPU_IMAGES_SITE_MAP["EUMETSAT"] + ) assert result["flavour_name"] == ewc_hub_config.DEFAULT_GPU_FLAVOURS_MAP["EUMETSAT"] + def test_resolve_ecmwf_gpu_defaults(conn, backend): - code, msg, result = resolve_image_and_flavor(conn, backend, federee="ECMWF", is_gpu=True) + code, msg, result = resolve_image_and_flavor( + conn, backend, federee="ECMWF", is_gpu=True + ) assert code == 0 - assert result["normalized_image_name"] == ewc_hub_config.EWC_CLI_OS_GPU_IMAGES_SITE_MAP["ECMWF"] + assert ( + result["normalized_image_name"] + == ewc_hub_config.EWC_CLI_OS_GPU_IMAGES_SITE_MAP["ECMWF"] + ) assert result["flavour_name"] == ewc_hub_config.DEFAULT_GPU_FLAVOURS_MAP["ECMWF"] + def test_resolve_specific_image(conn, backend): code, msg, result = resolve_image_and_flavor( conn, backend, federee="EUMETSAT", image_name="Ubuntu-22.04-20250202020202", - flavour_name="vm.a6000.1" + flavour_name="vm.a6000.1", ) assert code == 0 @@ -107,16 +122,15 @@ def test_resolve_specific_image(conn, backend): assert result["normalized_image_name"] == normalized assert result["flavour_name"] == "vm.a6000.1" + def test_resolve_unknown_image(conn, backend): code, msg, result = resolve_image_and_flavor( - conn, - backend, - federee="EUMETSAT", - image_name="Unknown-OS-1234" + conn, backend, federee="EUMETSAT", image_name="Unknown-OS-1234" ) assert code == 1 assert "Unsupported OS image" in msg + ################################################################################################# # --- Tests --- def test_get_deployed_server_info_eumetsat_private_and_manila(): @@ -205,22 +219,34 @@ def test_pre_deploy_server_setup_invalid_encoded_keys(conn): "networks": ("private",), } - with patch("ewccli.commands.commons_infra.check_ssh_keys_exist"), \ - patch("ewccli.commands.commons_infra.resolve_image_and_flavor", - return_value=(0, "ok", { - "image_name": "Ubuntu-22.04", - "normalized_image_name": "Ubuntu-22.04", - "flavour_name": "m1.small" - })), \ - patch("ewccli.commands.commons_infra.save_encoded_ssh_keys", - return_value=(False, False)): - + with ( + patch("ewccli.ssh_keys_manager.SSHKeyManager.keys_exist", return_value=True), + patch( + "ewccli.commands.commons_infra.resolve_image_and_flavor", + return_value=( + 0, + "ok", + { + "image_name": "Ubuntu-22.04", + "normalized_image_name": "Ubuntu-22.04", + "flavour_name": "m1.small", + }, + ), + ), + patch( + "ewccli.ssh_keys_manager.SSHKeyManager.save_encoded_keys", + return_value=(False, False), + ), + ): code, msg, outputs = pre_deploy_server_setup( - backend, conn, "EUMETSAT", server_inputs, + backend, + conn, + "EUMETSAT", + server_inputs, ssh_public_key_path="/tmp/id.pub", ssh_private_key_path="/tmp/id", ssh_private_encoded="AAA", - ssh_public_encoded="BBB" + ssh_public_encoded="BBB", ) assert code == 1 @@ -242,18 +268,28 @@ def test_pre_deploy_server_setup_success(conn): "networks": ("private",), } - with patch("ewccli.commands.commons_infra.check_ssh_keys_exist"), \ - patch("ewccli.commands.commons_infra.resolve_image_and_flavor", - return_value=(0, "ok", { - "image_name": "Ubuntu-22.04", - "normalized_image_name": "Ubuntu-22.04", - "flavour_name": "m1.small" - })): - + with ( + patch("ewccli.ssh_keys_manager.SSHKeyManager.keys_exist"), + patch( + "ewccli.commands.commons_infra.resolve_image_and_flavor", + return_value=( + 0, + "ok", + { + "image_name": "Ubuntu-22.04", + "normalized_image_name": "Ubuntu-22.04", + "flavour_name": "m1.small", + }, + ), + ), + ): code, msg, outputs = pre_deploy_server_setup( - backend, conn, "EUMETSAT", server_inputs, + backend, + conn, + "EUMETSAT", + server_inputs, ssh_public_key_path="/tmp/id.pub", - ssh_private_key_path="/tmp/id" + ssh_private_key_path="/tmp/id", ) assert code == 0 @@ -276,18 +312,28 @@ def test_pre_deploy_server_setup_invalid_inputs(conn): "networks": ("private",), } - with patch("ewccli.commands.commons_infra.check_ssh_keys_exist"), \ - patch("ewccli.commands.commons_infra.resolve_image_and_flavor", - return_value=(0, "ok", { - "image_name": "Ubuntu-22.04", - "normalized_image_name": "Ubuntu-22.04", - "flavour_name": "m1.small" - })): - + with ( + patch("ewccli.ssh_keys_manager.SSHKeyManager.keys_exist"), + patch( + "ewccli.commands.commons_infra.resolve_image_and_flavor", + return_value=( + 0, + "ok", + { + "image_name": "Ubuntu-22.04", + "normalized_image_name": "Ubuntu-22.04", + "flavour_name": "m1.small", + }, + ), + ), + ): code, msg, outputs = pre_deploy_server_setup( - backend, conn, "EUMETSAT", server_inputs, + backend, + conn, + "EUMETSAT", + server_inputs, ssh_public_key_path="/tmp/id.pub", - ssh_private_key_path="/tmp/id" + ssh_private_key_path="/tmp/id", ) assert code == 1 @@ -305,7 +351,7 @@ def test_identify_server_reconfiguration_existing_server(conn): pre_deploy_server_outputs = { "resolved_image_name": "Ubuntu-22.04", - "resolved_flavour_name": "m1.small" + "resolved_flavour_name": "m1.small", } fake_server = MagicMock() @@ -317,12 +363,10 @@ def test_identify_server_reconfiguration_existing_server(conn): with patch( "ewccli.commands.commons_infra.check_server_conflict_with_inputs", - return_value={} + return_value={}, ): code, msg, outputs = identify_server_reconfiguration( - conn, - server_inputs, - pre_deploy_server_outputs + conn, server_inputs, pre_deploy_server_outputs ) assert code == 0 @@ -330,7 +374,6 @@ def test_identify_server_reconfiguration_existing_server(conn): assert outputs == {} - def test_identify_server_reconfiguration_wrong_origin(conn): server_inputs = { "server_name": "vm1", @@ -342,7 +385,7 @@ def test_identify_server_reconfiguration_wrong_origin(conn): pre_deploy_server_outputs = { "resolved_image_name": "Ubuntu-22.04", - "resolved_flavour_name": "m1.small" + "resolved_flavour_name": "m1.small", } fake_server = MagicMock() @@ -351,9 +394,7 @@ def test_identify_server_reconfiguration_wrong_origin(conn): conn.get_server.return_value = fake_server code, msg, outputs = identify_server_reconfiguration( - conn, - server_inputs, - pre_deploy_server_outputs + conn, server_inputs, pre_deploy_server_outputs ) assert code == 1 @@ -361,12 +402,13 @@ def test_identify_server_reconfiguration_wrong_origin(conn): assert outputs == {} - def test_deploy_server_success(conn): backend = MagicMock() backend.create_server.return_value = ( - (True,), "server created", {"image": {"id": "img123"}} + (True,), + "server created", + {"image": {"id": "img123"}}, ) conn.compute.find_image.return_value = MagicMock(name="Ubuntu-22.04") @@ -381,7 +423,7 @@ def test_deploy_server_success(conn): pre_deploy_server_outputs = { "resolved_image_name": "Ubuntu-22.04", - "resolved_flavour_name": "m1.small" + "resolved_flavour_name": "m1.small", } code, msg, outputs = deploy_server( @@ -395,9 +437,7 @@ def test_deploy_server_success(conn): def test_deploy_server_failure(conn): backend = MagicMock() - backend.create_server.return_value = ( - (False,), "failed to create", None - ) + backend.create_server.return_value = ((False,), "failed to create", None) server_inputs = { "server_name": "vm1", @@ -410,7 +450,7 @@ def test_deploy_server_failure(conn): pre_deploy_server_outputs = { "resolved_image_name": "Ubuntu-22.04", - "resolved_flavour_name": "m1.small" + "resolved_flavour_name": "m1.small", } code, msg, outputs = deploy_server( @@ -437,9 +477,12 @@ def test_post_deploy_server_setup_success(conn): # first call (before adding IP) (0, "ok", {"internal_ip_machine": "10.0.0.5"}), # second call (after refresh) - (0, "ok", {"internal_ip_machine": "10.0.0.5", - "external_ip_machine": "1.2.3.4"}) - ] + ( + 0, + "ok", + {"internal_ip_machine": "10.0.0.5", "external_ip_machine": "1.2.3.4"}, + ), + ], ): server_inputs = { "server_name": "vm1", @@ -451,7 +494,7 @@ def test_post_deploy_server_setup_success(conn): conn, "EUMETSAT", server_inputs, - initial_server_info, # <-- NEW ARGUMENT + initial_server_info, # <-- NEW ARGUMENT ) assert code == 0 @@ -465,8 +508,7 @@ def test_post_deploy_server_setup_missing_ip(conn): initial_server_info = MagicMock() with patch( - "ewccli.commands.commons_infra.resolve_machine_ip", - return_value=(0, "ok", None) + "ewccli.commands.commons_infra.resolve_machine_ip", return_value=(0, "ok", None) ): server_inputs = { "server_name": "vm1", @@ -478,15 +520,13 @@ def test_post_deploy_server_setup_missing_ip(conn): conn, "EUMETSAT", server_inputs, - initial_server_info, # <-- NEW ARGUMENT + initial_server_info, # <-- NEW ARGUMENT ) assert code == 1 assert "No IPs identified" in msg -from unittest.mock import MagicMock, patch - def test_create_server_command_success(conn): backend = MagicMock() @@ -505,23 +545,30 @@ def test_create_server_command_success(conn): "security_groups": ("ssh",), } - deploy_outputs = { - "server_info": {"id": "123", "name": "vm1"} - } + deploy_outputs = {"server_info": {"id": "123", "name": "vm1"}} post_deploy_outputs = { "internal_ip_machine": "10.0.0.5", "external_ip_machine": "1.2.3.4", } - with patch("ewccli.commands.commons_infra.pre_deploy_server_setup", - return_value=(0, "ok", pre_deploy_outputs)) as mock_pre, \ - patch("ewccli.commands.commons_infra.identify_server_reconfiguration") as mock_identify, \ - patch("ewccli.commands.commons_infra.deploy_server", - return_value=(0, "ok", deploy_outputs)) as mock_deploy, \ - patch("ewccli.commands.commons_infra.post_deploy_server_setup", - return_value=(0, "ok", post_deploy_outputs)) as mock_post: - + with ( + patch( + "ewccli.commands.commons_infra.pre_deploy_server_setup", + return_value=(0, "ok", pre_deploy_outputs), + ) as mock_pre, + patch( + "ewccli.commands.commons_infra.identify_server_reconfiguration" + ) as mock_identify, + patch( + "ewccli.commands.commons_infra.deploy_server", + return_value=(0, "ok", deploy_outputs), + ) as mock_deploy, + patch( + "ewccli.commands.commons_infra.post_deploy_server_setup", + return_value=(0, "ok", post_deploy_outputs), + ) as mock_post, + ): from ewccli.commands.commons_infra import create_server_command code, msg, outputs = create_server_command( diff --git a/ewccli/tests/ewccli_commands_common_test.py b/ewccli/tests/ewccli_commands_common_test.py new file mode 100644 index 0000000..0763ce1 --- /dev/null +++ b/ewccli/tests/ewccli_commands_common_test.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python +# +# Package Name: ewccli +# License: GPL-3.0-or-later +# Copyright (c) 2025, 2026 EUMETSAT, ECMWF for European Weather Cloud +# See the LICENSE file for more details + + +"""Tests for EWC commands common methods.""" + +from __future__ import annotations + +import pytest +from rich.console import Console +from rich.table import Table + +from ewccli.commands.commons import ( + show_item_table, +) + + +@pytest.fixture +def console_capture(monkeypatch): + """ + Capture Rich console output so we can assert on the rendered table. + """ + console = Console(record=True) + monkeypatch.setattr("ewccli.commands.commons.console", console) + return console + + +@pytest.fixture +def sample_hub_item(): + return { + "name": "demo-item", + "version": "1.0.0", + "summary": "A demo item", + "maintainers": [ + {"name": "Alice", "url": "https://example.com"}, + {"name": "Bob", "email": "bob@example.com"}, + ], + "annotations": { + "category": "test", + "tier": "gold", + }, + "description": "This is a test description.", + "ewccli": { + "inputs": [ + { + "name": "param1", + "type": "string", + "description": "First parameter", + "default": "abc", + }, + { + "name": "param2", + "type": "int", + "description": "Second parameter", + }, + ], + "defaultImageName": "ubuntu:22.04", + "defaultSecurityGroups": ["sg-123", "sg-456"], + }, + } + + +def test_show_item_table_renders_basic_fields(console_capture, sample_hub_item): + show_item_table(sample_hub_item) + + output = console_capture.export_text() + + assert "demo-item EWC Item Details" in output + assert "name" in output + assert "demo-item" in output + assert "version" in output + assert "1.0.0" in output + assert "summary" in output + assert "A demo item" in output + + +def test_show_item_table_renders_maintainers(console_capture, sample_hub_item): + show_item_table(sample_hub_item) + output = console_capture.export_text() + + assert "Alice" in output + assert "https://example.com" in output + assert "Bob" in output + assert "bob@example.com" in output + + +def test_show_item_table_renders_annotations(console_capture, sample_hub_item): + show_item_table(sample_hub_item) + output = console_capture.export_text() + + assert "category" in output + assert "test" in output + assert "tier" in output + assert "gold" in output + + +def test_show_item_table_renders_description(console_capture, sample_hub_item): + show_item_table(sample_hub_item) + output = console_capture.export_text() + + assert "This is a test description." in output + + +def test_show_item_table_renders_inputs(console_capture, sample_hub_item): + show_item_table(sample_hub_item) + output = console_capture.export_text() + + # param1 has default → optional + assert "(optional)" in output + assert "param1" in output + assert "(default: abc)" in output + + # param2 has no default → mandatory + assert "(mandatory)" in output + assert "param2" in output + + +def test_show_item_table_renders_deploy_command(console_capture, sample_hub_item): + show_item_table(sample_hub_item) + output = console_capture.export_text() + + assert "ewc hub deploy demo-item" in output + # param2 is mandatory → must appear in deploy command + assert "--item-inputs param2" in output + + +def test_show_item_table_renders_defaults(console_capture, sample_hub_item): + show_item_table(sample_hub_item) + output = console_capture.export_text() + + assert "Image Name: ubuntu:22.04" in output + assert "Security Group/s: sg-123,sg-456" in output diff --git a/ewccli/tests/ewccli_commands_hub_deploy_server_test.py b/ewccli/tests/ewccli_commands_hub_deploy_server_test.py index f920510..a8dde8a 100644 --- a/ewccli/tests/ewccli_commands_hub_deploy_server_test.py +++ b/ewccli/tests/ewccli_commands_hub_deploy_server_test.py @@ -27,6 +27,7 @@ # Fake image using Pydantic for strict validation # --------------------------------------------------------------------------- + class FakeImage(BaseModel): name: str created_at: datetime @@ -50,9 +51,12 @@ def finder(): # CPU: Rocky-8 # --------------------------------------------------------------------------- + def test_find_latest_rocky8_cpu(finder, conn, monkeypatch): now = datetime.utcnow() - img_old = FakeImage(name="Rocky-8.9-20250101010101", created_at=now - timedelta(days=10)) + img_old = FakeImage( + name="Rocky-8.9-20250101010101", created_at=now - timedelta(days=10) + ) img_new = FakeImage(name="Rocky-8.9-20250202020202", created_at=now) conn.compute.images.return_value = [img_old, img_new] @@ -71,9 +75,12 @@ def test_find_latest_rocky8_cpu(finder, conn, monkeypatch): # CPU: Ubuntu 22.04 # --------------------------------------------------------------------------- + def test_find_latest_ubuntu_2204_cpu(finder, conn, monkeypatch): now = datetime.utcnow() - img1 = FakeImage(name="Ubuntu-22.04-20250101010101", created_at=now - timedelta(days=5)) + img1 = FakeImage( + name="Ubuntu-22.04-20250101010101", created_at=now - timedelta(days=5) + ) img2 = FakeImage(name="Ubuntu-22.04-20250303030303", created_at=now) conn.compute.images.return_value = [img1, img2] @@ -91,9 +98,12 @@ def test_find_latest_ubuntu_2204_cpu(finder, conn, monkeypatch): # GPU: Rocky # --------------------------------------------------------------------------- + def test_find_latest_rocky_gpu(finder, conn, monkeypatch): now = datetime.utcnow() - img1 = FakeImage(name="Rocky-9.6-GPU-20250101010101", created_at=now - timedelta(days=3)) + img1 = FakeImage( + name="Rocky-9.6-GPU-20250101010101", created_at=now - timedelta(days=3) + ) img2 = FakeImage(name="Rocky-9.6-GPU-20250303030303", created_at=now) conn.compute.images.return_value = [img1, img2] @@ -111,6 +121,7 @@ def test_find_latest_rocky_gpu(finder, conn, monkeypatch): # GPU: Ubuntu # --------------------------------------------------------------------------- + def test_find_latest_ubuntu_gpu(finder, conn, monkeypatch): now = datetime.utcnow() img1 = FakeImage(name="Ubuntu 22.04 NVIDIA_AI", created_at=now - timedelta(days=1)) @@ -137,6 +148,7 @@ def test_find_latest_ubuntu_gpu(finder, conn, monkeypatch): # No match # --------------------------------------------------------------------------- + def test_no_matching_images(finder, conn, monkeypatch): conn.compute.images.return_value = [ FakeImage(name="UnrelatedImage", created_at=datetime.utcnow()) @@ -152,6 +164,7 @@ def test_no_matching_images(finder, conn, monkeypatch): # Pydantic models + class IPResult(BaseModel): """ Pydantic model representing the result of resolving a machine's IPs. @@ -284,27 +297,27 @@ def test_resolve_machine_ip(federee, server_info, expected_status, expected_resu # Tests # ====================================================================== + @pytest.fixture(autouse=True) def clean_config(monkeypatch): - monkeypatch.setattr( - ewc_hub_config, - "EWC_CLI_CPU_IMAGES", - set() - ) + monkeypatch.setattr(ewc_hub_config, "EWC_CLI_CPU_IMAGES", set()) monkeypatch.setattr( ewc_hub_config, "EWC_CLI_OS_GPU_IMAGES_SITE_MAP", { "ECMWF": "Rocky-9.6-GPU", "EUMETSAT": "Ubuntu-22.04-NVIDIA_AI", - } + }, ) # ----------------------- CPU Tests ----------------------- + def test_exact_cpu_match(monkeypatch): - monkeypatch.setattr(ewc_hub_config, "EWC_CLI_CPU_IMAGES", {"Rocky-8", "Ubuntu-22.04"}) + monkeypatch.setattr( + ewc_hub_config, "EWC_CLI_CPU_IMAGES", {"Rocky-8", "Ubuntu-22.04"} + ) normalized, exact = normalize_os_image("Rocky-8", "ECMWF") assert normalized == "Rocky-8" @@ -329,11 +342,12 @@ def test_normalize_ubuntu_timestamp(monkeypatch): # ----------------------- EUMETSAT GPU ----------------------- + def test_eumetsat_exact_gpu(monkeypatch): monkeypatch.setattr( ewc_hub_config, "EWC_CLI_OS_GPU_IMAGES_SITE_MAP", - {"EUMETSAT": "Ubuntu-22.04-NVIDIA_AI"} + {"EUMETSAT": "Ubuntu-22.04-NVIDIA_AI"}, ) normalized, exact = normalize_os_image("Ubuntu-22.04-NVIDIA_AI", "EUMETSAT") @@ -345,7 +359,7 @@ def test_eumetsat_translate_generic_gpu(monkeypatch): monkeypatch.setattr( ewc_hub_config, "EWC_CLI_OS_GPU_IMAGES_SITE_MAP", - {"EUMETSAT": "Ubuntu-22.04-NVIDIA_AI"} + {"EUMETSAT": "Ubuntu-22.04-NVIDIA_AI"}, ) normalized, exact = normalize_os_image("Ubuntu-22.04-GPU", "EUMETSAT") @@ -355,11 +369,10 @@ def test_eumetsat_translate_generic_gpu(monkeypatch): # ----------------------- ECMWF GPU ----------------------- + def test_ecmwf_exact_gpu(monkeypatch): monkeypatch.setattr( - ewc_hub_config, - "EWC_CLI_OS_GPU_IMAGES_SITE_MAP", - {"ECMWF": "Rocky-9-GPU"} + ewc_hub_config, "EWC_CLI_OS_GPU_IMAGES_SITE_MAP", {"ECMWF": "Rocky-9-GPU"} ) normalized, exact = normalize_os_image("Rocky-9-GPU", "ECMWF") @@ -369,9 +382,7 @@ def test_ecmwf_exact_gpu(monkeypatch): def test_ecmwf_timestamp_gpu(monkeypatch): monkeypatch.setattr( - ewc_hub_config, - "EWC_CLI_OS_GPU_IMAGES_SITE_MAP", - {"ECMWF": "Rocky-9-GPU"} + ewc_hub_config, "EWC_CLI_OS_GPU_IMAGES_SITE_MAP", {"ECMWF": "Rocky-9-GPU"} ) normalized, exact = normalize_os_image("Rocky-9.6-GPU-20250101010101", "ECMWF") @@ -381,10 +392,10 @@ def test_ecmwf_timestamp_gpu(monkeypatch): # ----------------------- Unknown ----------------------- + def test_unknown_image(monkeypatch): monkeypatch.setattr(ewc_hub_config, "EWC_CLI_CPU_IMAGES", {"Rocky-8"}) normalized, exact = normalize_os_image("NotAnImage", "EUMETSAT") assert normalized is None assert exact is False - diff --git a/ewccli/tests/ewccli_commands_hub_inputs_validation_test.py b/ewccli/tests/ewccli_commands_hub_inputs_validation_test.py index 5b682e4..db9c31d 100644 --- a/ewccli/tests/ewccli_commands_hub_inputs_validation_test.py +++ b/ewccli/tests/ewccli_commands_hub_inputs_validation_test.py @@ -65,13 +65,13 @@ def item_schema() -> list: @pytest.mark.parametrize( "field,value", [ - ("password_allowed_ip_ranges", [123]), # List[str] expected - ("password_allowed_ip_ranges", [None]), # None not allowed inside list + ("password_allowed_ip_ranges", [123]), # List[str] expected + ("password_allowed_ip_ranges", [None]), # None not allowed inside list ("password_allowed_ip_ranges", "not-a-list"), # wrong type - ("ipa_client_hostname", 456), # should be str - ("ipa_domain", True), # should be str - ("ipa_admin_password", {}), # should be str - ("ipa_admin_username", []), # should be str + ("ipa_client_hostname", 456), # should be str + ("ipa_domain", True), # should be str + ("ipa_admin_password", {}), # should be str + ("ipa_admin_username", []), # should be str ], ) def test_invalid_inputs_return_error(item_schema, valid_inputs, field, value): @@ -85,10 +85,12 @@ def test_invalid_inputs_return_error(item_schema, valid_inputs, field, value): assert field in result assert "expected type" in result + # ------------------------------------------------------------ # Valid list and Optional inputs # ------------------------------------------------------------ + def test_list_of_strings_valid(item_schema, valid_inputs): modified = valid_inputs.copy() modified["password_allowed_ip_ranges"] = ["a", "b"] @@ -122,10 +124,12 @@ def test_none_parsed_inputs_returns_empty_string(item_schema): """Test that passing None as parsed_inputs returns an empty string.""" assert validate_item_input_types(None, item_schema) == "" + # ------------------------------------------------------------ # Literal parsing cases # ------------------------------------------------------------ + def test_unquoted_list_string_is_invalid(): """ Your implementation treats a quoted list string (\"['a','b']\") as invalid for List[str]. @@ -185,7 +189,9 @@ def test_parsing_of_literal_eval_strings_fails_for_list(): def test_no_required_inputs(): """If no required inputs are defined, should return an empty list.""" - assert check_missing_required_inputs(parsed_inputs={}, required_item_inputs=[]) == [] + assert ( + check_missing_required_inputs(parsed_inputs={}, required_item_inputs=[]) == [] + ) def test_all_required_inputs_provided(): @@ -230,10 +236,11 @@ def test_prepare_missing_inputs_error_message(): # NEW TESTS for empty values in the input variables # ------------------------------------------------- + def test_empty_string_value_is_accepted(item_schema, valid_inputs): """Ensure key="" is treated as valid (empty string), not an error.""" modified = valid_inputs.copy() - modified["ipa_domain"] = "" # empty string allowed + modified["ipa_domain"] = "" # empty string allowed result = validate_item_input_types(modified, item_schema) assert result == "" @@ -257,11 +264,16 @@ def test_empty_string_after_literal_eval(item_schema, valid_inputs): result = validate_item_input_types(parsed, item_schema) assert result == "" + + # ------------------------------------------------------------ # Missing message formatting # ------------------------------------------------------------ + def test_prepare_missing_inputs_error_message(): missing = ["ipa_domain", "ipa_admin_password"] msg = prepare_missing_inputs_error_message(missing) - assert msg == "Missing 2 required item input(s):\n- ipa_domain\n- ipa_admin_password" + assert ( + msg == "Missing 2 required item input(s):\n- ipa_domain\n- ipa_admin_password" + ) diff --git a/ewccli/tests/ewccli_config_test.py b/ewccli/tests/ewccli_config_test.py index 7e8d2c0..ba47dc3 100644 --- a/ewccli/tests/ewccli_config_test.py +++ b/ewccli/tests/ewccli_config_test.py @@ -8,26 +8,23 @@ """Test config methods.""" -import click import pytest +import click -# Import your new unified API -from ewccli.utils import ( - save_cli_profile, - load_cli_profile, - _resolve_profile, -) +from configparser import ConfigParser +from ewccli.profile import ProfileStore, ProfileData +from ewccli.enums import Federee @pytest.fixture -def profile_file_path(tmp_path): - """Return a temporary path for profiles file.""" - return tmp_path / "profiles" +def profile_file(tmp_path): + """Temporary profiles file path.""" + return tmp_path / "profiles.ini" @pytest.fixture def ssh_paths(tmp_path): - """Create fake ssh key paths.""" + """Create fake SSH key files.""" priv = tmp_path / "id_rsa" pub = tmp_path / "id_rsa.pub" @@ -37,100 +34,254 @@ def ssh_paths(tmp_path): return str(priv), str(pub) -def test_save_and_load_profile(profile_file_path, ssh_paths): - federee = "EUMETSAT" - tenant_name = "TeamA" - token = "tok1" - app_id = "ID1" - app_secret = "SECRET1" - region = "us-east-1" - - ssh_private, ssh_public = ssh_paths - - save_cli_profile( - federee=federee, - tenant_name=tenant_name, +def make_profile_data( + federee: str, + tenant: str, + ssh_private: str, + ssh_public: str, + **extra, +) -> ProfileData: + """Helper to build ProfileData.""" + return ProfileData( + federee=Federee(federee), + tenant_name=tenant, ssh_private_key_path_to_save=ssh_private, ssh_public_key_path_to_save=ssh_public, - token=token, - application_credential_id=app_id, - application_credential_secret=app_secret, - region=region, - profiles_file_path=str(profile_file_path), + **extra, ) - profile_name = _resolve_profile(None, federee, tenant_name) - data = load_cli_profile( - profile=profile_name, - profiles_file_path=str(profile_file_path), +# --------------------------------------------------------------------------- +# Save + Load +# --------------------------------------------------------------------------- + +def test_save_and_load_profile(profile_file, ssh_paths): + store = ProfileStore(path=profile_file) + + data = make_profile_data( + federee="EUMETSAT", + tenant="TeamA", + ssh_private=ssh_paths[0], + ssh_public=ssh_paths[1], + token="tok1", + application_credential_id="ID1", + application_credential_secret="SECRET1", + region="us-east-1", ) - assert data["profile"] == profile_name - assert data["federee"] == federee - assert data["tenant_name"] == tenant_name - assert data["token"] == token - assert data["application_credential_id"] == app_id - assert data["application_credential_secret"] == app_secret - assert data["region"] == region - assert data["ssh_private_key_path"] == ssh_private - assert data["ssh_public_key_path"] == ssh_public - - -def test_save_existing_profile_fails(profile_file_path, ssh_paths): - federee = "EWC2" - tenant_name = "TeamB" - ssh_private, ssh_public = ssh_paths - - save_cli_profile( - federee, - tenant_name, - ssh_private, - ssh_public, - profiles_file_path=str(profile_file_path), + store.save(data) + + loaded = store.load(data.profile or "EUMETSAT-TeamA") + + assert loaded.federee == data.federee + assert loaded.tenant_name == data.tenant_name + assert loaded.token == "tok1" + assert loaded.application_credential_id == "ID1" + assert loaded.application_credential_secret == "SECRET1" + assert loaded.region == "us-east-1" + assert loaded.ssh_private_key_path == ssh_paths[0] + assert loaded.ssh_public_key_path == ssh_paths[1] + + +# --------------------------------------------------------------------------- +# Duplicate profile +# --------------------------------------------------------------------------- + +def test_save_existing_profile_fails(profile_file, ssh_paths): + store = ProfileStore(path=profile_file) + + data = make_profile_data( + federee="EUMETSAT", + tenant="TeamB", + ssh_private=ssh_paths[0], + ssh_public=ssh_paths[1], ) + store.save(data) + with pytest.raises(click.Abort): - save_cli_profile( - federee, - tenant_name, - ssh_private, - ssh_public, - profiles_file_path=str(profile_file_path), - ) + store.save(data) + + +# --------------------------------------------------------------------------- +# Missing profile +# --------------------------------------------------------------------------- +def test_load_missing_profile_raises(profile_file): + store = ProfileStore(path=profile_file) -def test_load_missing_profile_raises(profile_file_path): with pytest.raises(click.Abort): - load_cli_profile( - profile="nonexistent", - profiles_file_path=str(profile_file_path), - ) + store.load("nonexistent") + + +# --------------------------------------------------------------------------- +# resolve_name +# --------------------------------------------------------------------------- + +def test_resolve_name(profile_file): + store = ProfileStore(path=profile_file) + + assert store.resolve_name("explicit", None, None) == "explicit" + assert store.resolve_name(None, "EUMETSAT", "TeamA") == "EUMETSAT-TeamA" with pytest.raises(click.Abort): - load_cli_profile( - profiles_file_path=str(profile_file_path), - ) - - -def test_overwrite_profile_not_allowed(profile_file_path, ssh_paths): - federee = "EWC5" - tenant_name = "TeamE" - ssh_private, ssh_public = ssh_paths - - save_cli_profile( - federee, - tenant_name, - ssh_private, - ssh_public, - profiles_file_path=str(profile_file_path), - ) + store.resolve_name(None, None, "TeamA") with pytest.raises(click.Abort): - save_cli_profile( - federee, - tenant_name, - ssh_private, - ssh_public, - profiles_file_path=str(profile_file_path), - ) + store.resolve_name(None, "EUMETSAT", None) + +# --------------------------------------------------------------------------- +# ProfileData.from_section +# --------------------------------------------------------------------------- + +def test_profiledata_from_section_valid(tmp_path): + cfg = ConfigParser() + cfg["EUMETSAT-TeamA"] = { + "federee": "EUMETSAT", + "tenant_name": "TeamA", + "ssh_public_key_path": "/tmp/pub", + "ssh_private_key_path": "/tmp/priv", + "region": "eu-west-1", + "token": "tok", + "application_credential_id": "ID", + "application_credential_secret": "SECRET", + } + + section = cfg["EUMETSAT-TeamA"] + data = ProfileData.from_section("EUMETSAT-TeamA", section) + + assert data.federee == Federee.EUMETSAT + assert data.tenant_name == "TeamA" + assert data.region == "eu-west-1" + assert data.token == "tok" + assert data.application_credential_id == "ID" + assert data.application_credential_secret == "SECRET" + + +def test_profiledata_from_section_invalid(tmp_path): + cfg = ConfigParser() + cfg["bad"] = { + "federee": "INVALID", # not a valid enum + "tenant_name": "TeamA", + "ssh_public_key_path": "/tmp/pub", + "ssh_private_key_path": "/tmp/priv", + } + + with pytest.raises(click.ClickException): + ProfileData.from_section("bad", cfg["bad"]) + + +# --------------------------------------------------------------------------- +# ProfileData.to_section +# --------------------------------------------------------------------------- + +def test_profiledata_to_section_roundtrip(ssh_paths): + priv, pub = ssh_paths + + data = ProfileData( + federee=Federee.ECMWF, + tenant_name="Ops", + ssh_private_key_path_to_save=priv, + ssh_public_key_path_to_save=pub, + region="eu-central-1", + token="T", + application_credential_id="ID", + application_credential_secret="SECRET", + ) + + section = data.to_section() + + assert section["federee"] == "ECMWF" + assert section["tenant_name"] == "Ops" + assert section["ssh_private_key_path"] == priv + assert section["ssh_public_key_path"] == pub + assert section["region"] == "eu-central-1" + assert section["token"] == "T" + assert section["application_credential_id"] == "ID" + assert section["application_credential_secret"] == "SECRET" + + +# --------------------------------------------------------------------------- +# save_default +# --------------------------------------------------------------------------- + +def test_save_default_creates_only_if_missing(profile_file, ssh_paths, monkeypatch): + monkeypatch.setattr( + "ewccli.configuration.config.EWC_CLI_DEFAULT_PROFILE_NAME", + "default", + ) + + store = ProfileStore(path=profile_file) + + data = ProfileData( + federee=Federee.EUMETSAT, + tenant_name="TeamA", + ssh_private_key_path_to_save=ssh_paths[0], + ssh_public_key_path_to_save=ssh_paths[1], + ) + + store.save_default(data) + assert store.exists("default") + + # Second call must NOT overwrite + store.save_default(data) + assert store.exists("default") + + +# --------------------------------------------------------------------------- +# list_profiles +# --------------------------------------------------------------------------- + +def test_list_profiles(profile_file, ssh_paths): + store = ProfileStore(path=profile_file) + + p1 = ProfileData( + federee=Federee.EUMETSAT, + tenant_name="A", + ssh_private_key_path_to_save=ssh_paths[0], + ssh_public_key_path_to_save=ssh_paths[1], + ) + p2 = ProfileData( + federee=Federee.ECMWF, + tenant_name="B", + ssh_private_key_path_to_save=ssh_paths[0], + ssh_public_key_path_to_save=ssh_paths[1], + ) + + store.save(p1) + store.save(p2) + + profiles = store.list_profiles() + assert set(profiles) == {"EUMETSAT-A", "ECMWF-B"} + + +# --------------------------------------------------------------------------- +# exists +# --------------------------------------------------------------------------- + +def test_exists(profile_file, ssh_paths): + store = ProfileStore(path=profile_file) + + data = ProfileData( + federee=Federee.EUMETSAT, + tenant_name="TeamX", + ssh_private_key_path_to_save=ssh_paths[0], + ssh_public_key_path_to_save=ssh_paths[1], + ) + + assert not store.exists("EUMETSAT-TeamX") + store.save(data) + assert store.exists("EUMETSAT-TeamX") + + +# --------------------------------------------------------------------------- +# Validation errors +# --------------------------------------------------------------------------- + +def test_profiledata_validation_missing_fields(): + with pytest.raises(Exception): + ProfileData( + federee=Federee.EUMETSAT, + tenant_name="TeamA", + # missing ssh keys → must fail + ) \ No newline at end of file diff --git a/ewccli/tests/ewccli_infra_test.py b/ewccli/tests/ewccli_infra_test.py index 1924d14..08fe372 100644 --- a/ewccli/tests/ewccli_infra_test.py +++ b/ewccli/tests/ewccli_infra_test.py @@ -18,6 +18,7 @@ # Fixtures # ------------------------- + @pytest.fixture def conn(): """Mock OpenStack connection.""" @@ -36,6 +37,7 @@ class FakeServer(SimpleNamespace): def get(self, key, default=None): return getattr(self, key, default) + def make_server(**kwargs): return FakeServer(**kwargs) @@ -44,6 +46,7 @@ def make_server(**kwargs): # Tests # ------------------------- + def test_basic_server(conn, backend): server = make_server( id="1", @@ -52,11 +55,7 @@ def test_basic_server(conn, backend): metadata={"deployed": "ewccli"}, image={"id": "img1"}, flavor={"original_name": "vm.a6000.4"}, - addresses={ - "private": [ - {"addr": "10.0.0.152", "OS-EXT-IPS:type": "fixed"} - ] - }, + addresses={"private": [{"addr": "10.0.0.152", "OS-EXT-IPS:type": "fixed"}]}, security_groups=[{"name": "default"}], ) diff --git a/ewccli/tests/ewccli_login_test.py b/ewccli/tests/ewccli_login_test.py index 4f577c9..ec727b1 100644 --- a/ewccli/tests/ewccli_login_test.py +++ b/ewccli/tests/ewccli_login_test.py @@ -8,11 +8,10 @@ """Tests for EWC login command.""" - import pytest from pathlib import Path from click import ClickException - +from ewccli.ssh_keys_manager import SSHKeyError from ewccli.commands.login_command import check_and_generate_ssh_keys @@ -28,8 +27,8 @@ def test_existing_matching_keys(tmp_path, monkeypatch): # Patch where function is USED monkeypatch.setattr( - "ewccli.commands.login_command.check_ssh_keys_match", - lambda ssh_private_key_path, ssh_public_key_path: True, + "ewccli.ssh_keys_manager.SSHKeyManager.keys_match", + lambda *args, **kwargs: True, ) result_priv, result_pub = check_and_generate_ssh_keys( @@ -53,8 +52,8 @@ def test_existing_mismatching_keys(tmp_path, monkeypatch): pub.write_text("public") monkeypatch.setattr( - "ewccli.commands.login_command.check_ssh_keys_match", - lambda ssh_private_key_path, ssh_public_key_path: False, + "ewccli.ssh_keys_manager.SSHKeyManager.keys_match", + lambda *args, **kwargs: (_ for _ in ()).throw(SSHKeyError("mismatch")), ) with pytest.raises(ClickException): @@ -72,20 +71,18 @@ def test_missing_keys_generate(tmp_path, monkeypatch): priv = tmp_path / "id_rsa" pub = tmp_path / "id_rsa.pub" - # Patch click.confirm in login_command monkeypatch.setattr( "ewccli.commands.login_command.click.confirm", lambda *args, **kwargs: True, ) - # Fake generate now only takes resolved_profile - def fake_generate(resolved_profile): + def fake_generate(self, resolved_profile): priv.write_text("generated private") pub.write_text("generated public") - return priv, pub + return str(priv), str(pub) monkeypatch.setattr( - "ewccli.commands.login_command.generate_ssh_keypair", + "ewccli.ssh_keys_manager.SSHKeyManager.generate_keypair", fake_generate, ) diff --git a/ewccli/tests/ewccli_ssh_keys_test.py b/ewccli/tests/ewccli_ssh_keys_test.py index 45acadb..d2e4aea 100644 --- a/ewccli/tests/ewccli_ssh_keys_test.py +++ b/ewccli/tests/ewccli_ssh_keys_test.py @@ -11,31 +11,25 @@ import base64 import pytest -import os -from pathlib import Path from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives import serialization -from ewccli.configuration import config as ewc_hub_config -from ewccli.utils import ( - load_ssh_private_key, - load_ssh_public_key, - verify_private_key, - save_ssh_key, - save_encoded_ssh_keys, - generate_ssh_keypair, -) +from ewccli.ssh_keys_manager import SSHKeyManager, SSHKeyError +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + @pytest.fixture -def valid_private_key_pem() -> str: - """ - Fixture that generates a valid RSA private key in PEM format. +def manager() -> SSHKeyManager: + return SSHKeyManager() - Returns: - str: PEM-encoded RSA private key. - """ + +@pytest.fixture +def valid_private_key_pem() -> str: + """Generate a valid RSA private key in PEM format.""" key = rsa.generate_private_key(public_exponent=65537, key_size=2048) pem = key.private_bytes( encoding=serialization.Encoding.PEM, @@ -47,16 +41,7 @@ def valid_private_key_pem() -> str: @pytest.fixture def valid_public_key_openssh(valid_private_key_pem: str) -> str: - """ - Fixture that derives a valid OpenSSH-formatted public key - from the generated private key. - - Args: - valid_private_key_pem (str): PEM-encoded private key. - - Returns: - str: OpenSSH public key string. - """ + """Derive a valid OpenSSH public key from the private key.""" private_key = serialization.load_pem_private_key( valid_private_key_pem.encode("utf-8"), password=None ) @@ -67,122 +52,137 @@ def valid_public_key_openssh(valid_private_key_pem: str) -> str: ).decode("utf-8") -def test_load_ssh_private_key_success(valid_private_key_pem): - """ - Test that `load_ssh_private_key` decodes base64-encoded PEM correctly. - """ - encoded = base64.b64encode(valid_private_key_pem.encode("utf-8")).decode("utf-8") - result = load_ssh_private_key(encoded_key=encoded) +# --------------------------------------------------------------------------- +# Decoding tests +# --------------------------------------------------------------------------- + +def test_load_private_encoded_success(manager, valid_private_key_pem): + encoded = base64.b64encode(valid_private_key_pem.encode()).decode() + result = manager.load_private_encoded(encoded) assert "BEGIN RSA PRIVATE KEY" in result -def test_load_ssh_private_key_missing(monkeypatch): - """ - Test that `load_ssh_private_key` exits with error when no key is provided. - """ - with pytest.raises(SystemExit): - load_ssh_private_key(encoded_key=None) +def test_load_private_encoded_missing(manager): + with pytest.raises(SSHKeyError): + manager.load_private_encoded(None) -def test_load_ssh_public_key_success(valid_public_key_openssh): - """ - Test that `load_ssh_public_key` decodes base64-encoded OpenSSH public key. - """ - encoded = base64.b64encode(valid_public_key_openssh.encode("utf-8")).decode("utf-8") - result = load_ssh_public_key(encoded_key=encoded) - assert result.startswith("ssh-rsa") +def test_load_public_encoded_success(manager, valid_public_key_openssh): + encoded = base64.b64encode(valid_public_key_openssh.encode()).decode() + result = manager.load_public_encoded(encoded) + assert result.startswith("ssh-") -def test_load_ssh_public_key_missing(): - """ - Test that `load_ssh_public_key` exits when key is not provided. - """ - with pytest.raises(SystemExit): - load_ssh_public_key(encoded_key=None) +def test_load_public_encoded_missing(manager): + with pytest.raises(SSHKeyError): + manager.load_public_encoded(None) -def test_verify_private_key_valid(valid_private_key_pem): - """ - Test that `verify_private_key` passes with a valid key. - """ - # Should not raise SystemExit - verify_private_key(valid_private_key_pem) +# --------------------------------------------------------------------------- +# Verification tests +# --------------------------------------------------------------------------- +def test_verify_private_valid(manager, valid_private_key_pem): + manager.verify_private(valid_private_key_pem) # Should not raise -def test_verify_private_key_invalid(): - """ - Test that `verify_private_key` exits with error on invalid key. - """ - with pytest.raises(SystemExit): - verify_private_key("this is not a valid key") +def test_verify_private_invalid(manager): + with pytest.raises(SSHKeyError): + manager.verify_private("not a real key") -def test_save_ssh_key_creates_file(tmp_path): - """ - Test that `save_ssh_key` writes a file with correct permissions. - """ + +# --------------------------------------------------------------------------- +# Saving tests +# --------------------------------------------------------------------------- + +def test_save_key_creates_file(manager, tmp_path): key_content = "dummy-key" path = tmp_path / "id_rsa" - save_ssh_key(ssh_key=key_content, path_key=str(path)) + manager.save_key(key_content, path) assert path.exists() assert path.read_text() == key_content assert oct(path.stat().st_mode & 0o777) == "0o600" -def test_save_ssh_keys_writes_files( - tmp_path, valid_private_key_pem, valid_public_key_openssh, monkeypatch -): - """ - Test that `save_ssh_keys` writes both private and public keys when provided. - """ +def test_save_encoded_keys(manager, tmp_path, valid_private_key_pem, valid_public_key_openssh): priv_path = tmp_path / "id_rsa" pub_path = tmp_path / "id_rsa.pub" - monkeypatch.setattr(ewc_hub_config, "EWC_CLI_PRIVATE_SSH_KEY_PATH", str(priv_path)) - monkeypatch.setattr(ewc_hub_config, "EWC_CLI_PUBLIC_SSH_KEY_PATH", str(pub_path)) - - encoded_priv = base64.b64encode(valid_private_key_pem.encode("utf-8")).decode( - "utf-8" - ) - encoded_pub = base64.b64encode(valid_public_key_openssh.encode("utf-8")).decode( - "utf-8" - ) + encoded_priv = base64.b64encode(valid_private_key_pem.encode()).decode() + encoded_pub = base64.b64encode(valid_public_key_openssh.encode()).decode() - save_encoded_ssh_keys( - ssh_public_key_path=pub_path, - ssh_private_key_path=priv_path, - ssh_public_encoded=encoded_pub, - ssh_private_encoded=encoded_priv + pub_written, priv_written = manager.save_encoded_keys( + public_path=pub_path, + private_path=priv_path, + public_encoded=encoded_pub, + private_encoded=encoded_priv, ) + assert pub_written is True + assert priv_written is True assert priv_path.exists() assert pub_path.exists() -def test_generate_ssh_keypair_creates_files(tmp_path, monkeypatch): - """ - Test that `generate_ssh_keypair` creates private and public key files. - """ - - # Patch the SSH repo path to tmp_path - monkeypatch.setattr( - ewc_hub_config, - "EWC_CLI_HUB_SSH_REPO_PATH", - tmp_path - ) +# --------------------------------------------------------------------------- +# Keypair generation tests +# --------------------------------------------------------------------------- - # Call function with just the resolved_profile - priv_path, pub_path = generate_ssh_keypair(resolved_profile="pytest") +def test_generate_keypair(manager, tmp_path, monkeypatch): + monkeypatch.setattr(manager, "repo_path", tmp_path) - priv_path = Path(priv_path) - pub_path = Path(pub_path) + priv_path, pub_path = manager.generate_keypair("pytest") - # Check that files were created assert priv_path.exists() assert pub_path.exists() - # Basic content checks assert "PRIVATE KEY" in priv_path.read_text() assert pub_path.read_text().startswith("ssh-") + + +# --------------------------------------------------------------------------- +# Existence + matching tests +# --------------------------------------------------------------------------- + +def test_keys_exist(manager, tmp_path): + priv = tmp_path / "id_rsa" + pub = tmp_path / "id_rsa.pub" + + priv.write_text("x") + pub.write_text("y") + + assert manager.keys_exist(priv, pub) is None + + +def test_keys_exist_missing(manager, tmp_path): + priv = tmp_path / "id_rsa" + pub = tmp_path / "id_rsa.pub" + + priv.write_text("x") + # pub missing + + with pytest.raises(SSHKeyError): + manager.keys_exist(priv, pub) + + +def test_keys_match_success(manager, tmp_path, valid_private_key_pem, valid_public_key_openssh): + priv = tmp_path / "id_rsa" + pub = tmp_path / "id_rsa.pub" + + priv.write_text(valid_private_key_pem) + pub.write_text(valid_public_key_openssh) + + assert manager.keys_match(priv, pub) is True + + +def test_keys_match_invalid(manager, tmp_path): + priv = tmp_path / "id_rsa" + pub = tmp_path / "id_rsa.pub" + + priv.write_text("not a real key") + pub.write_text("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQfake") + + with pytest.raises(SSHKeyError): + manager.keys_match(priv, pub) diff --git a/ewccli/utils.py b/ewccli/utils.py index bd4b7f9..8b629df 100644 --- a/ewccli/utils.py +++ b/ewccli/utils.py @@ -8,25 +8,13 @@ """Utils.""" -import os -import base64 -import sys import subprocess -from pathlib import Path import secrets import string from datetime import datetime, timezone -from typing import Optional, Tuple, IO, List, Dict +from typing import Optional, Tuple, List import requests -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.hazmat.backends import default_backend - -from configparser import ConfigParser - -import rich_click as click -from click import ClickException from ewccli.configuration import config as ewc_hub_config from ewccli.logger import get_logger @@ -34,318 +22,7 @@ _LOGGER = get_logger(__name__) -def _resolve_profile( - profile: Optional[str] = None, - federee: Optional[str] = None, - tenant_name: Optional[str] = None, -) -> str: - """Return explicit profile or auto-generate one using federee-tenant.""" - if profile is not None: - return profile - - if not federee or not tenant_name: - click.secho( - "❌ Either 'profile' must be provided or both 'federee' and 'tenant_name'.", - fg="red", - bold=True, - ) - raise click.Abort() - - return f"{federee.lower()}-{tenant_name.lower()}" - - -def save_default_login_profile( - federee: str, - tenant_name: str, - ssh_private_key_path_to_save: str, - ssh_public_key_path_to_save: str, - application_credential_id: Optional[str] = None, - application_credential_secret: Optional[str] = None, - region: Optional[str] = None, - token: Optional[str] = None, - profiles_file_path: Path = ewc_hub_config.EWC_CLI_PROFILES_PATH, -) -> None: - """ - Save the default login profile to EWC_CLI_PROFILES_PATH only if it does not exist. - If it already exists, do nothing (skip). - - Uses ewc_hub_config.EWC_CLI_DEFAULT_PROFILE_NAME as the profile name. - """ - resolved_profile = _resolve_profile( - profile=ewc_hub_config.EWC_CLI_DEFAULT_PROFILE_NAME, - ) - - cfg = ConfigParser() - cfg.read(profiles_file_path) - - # Skip saving if the default profile already exists - if resolved_profile in cfg: - return - - # Save profile (reusing the unified save_cli_profile logic) - - save_cli_profile( - federee=federee, - tenant_name=tenant_name, - ssh_private_key_path_to_save=ssh_private_key_path_to_save, - ssh_public_key_path_to_save=ssh_public_key_path_to_save, - profile=resolved_profile, - token=token, - application_credential_id=application_credential_id, - application_credential_secret=application_credential_secret, - region=region, - ) - - -def save_cli_profile( - federee: str, - tenant_name: str, - ssh_private_key_path_to_save: str, - ssh_public_key_path_to_save: str, - profile: Optional[str] = None, - token: Optional[str] = None, - application_credential_id: Optional[str] = None, - application_credential_secret: Optional[str] = None, - region: Optional[str] = None, - profiles_file_path: Path = ewc_hub_config.EWC_CLI_PROFILES_PATH, -) -> None: - """ - Save all profile data (config + credentials) into a single profiles file. - - Parameters - ---------- - federee : str - Federee name. - tenant_name : str - Tenant name. - ssh_private_key_path_to_save: str - SSH private key path - ssh_public_key_path_to_save: str - SSH public key path - profile : str, optional - Explicit profile name. If None, auto-generated using federee-tenant. - token : str, optional - Authentication token. - application_credential_id : str, optional - Application credential ID. - application_credential_secret : str, optional - Application credential secret. - region : str, optional - Region for the profile. - """ - resolved_profile = _resolve_profile(profile, federee, tenant_name) - cfg = ConfigParser() - cfg.read(profiles_file_path) - - # Fail if profile exists - if resolved_profile in cfg: - click.secho( - f"❌ Profile '{resolved_profile}' already exists in {profiles_file_path}", - fg="red", - bold=True, - ) - click.secho( - "Use a different profile name or delete the existing profile first.", - fg="yellow", - ) - raise click.Abort() - - # --- Save profile data - cfg[resolved_profile] = {} - - # Non-sensitive - cfg[resolved_profile]["federee"] = federee - cfg[resolved_profile]["tenant_name"] = tenant_name - cfg[resolved_profile]["ssh_public_key_path"] = ssh_public_key_path_to_save - cfg[resolved_profile]["ssh_private_key_path"] = ssh_private_key_path_to_save - - if region: - cfg[resolved_profile]["region"] = region - - # Sensitive - if token: - cfg[resolved_profile]["token"] = token - - if application_credential_id: - cfg[resolved_profile]["application_credential_id"] = application_credential_id - - if application_credential_secret: - cfg[resolved_profile][ - "application_credential_secret" - ] = application_credential_secret - - os.makedirs(os.path.dirname(profiles_file_path), exist_ok=True) - with open(profiles_file_path, "w") as f: - cfg.write(f) - - -def load_cli_profile( - profile: Optional[str] = None, - federee: Optional[str] = None, - tenant_name: Optional[str] = None, - profiles_file_path: Path = ewc_hub_config.EWC_CLI_PROFILES_PATH, - dry_run: bool = False -) -> Dict[str, Optional[str]]: - """ - Load all profile data (config + credentials) from the single profiles file. - - Parameters - ---------- - profile : str, optional - Explicit profile name to load. If None, auto-resolved from federee and tenant_name. - federee : str, optional - Federee name, used for auto-resolution if profile is None. - tenant_name : str, optional - Tenant name, used for auto-resolution if profile is None. - profiles_file_path : Path, default to ewc_hub_config.EWC_CLI_PROFILES_PATH - The path to the file with all profiles. - - Returns - ------- - dict - Combined profile data. - - Raises - ------ - click.Abort - If the profile cannot be found or cannot be resolved. - """ - if dry_run: - return {} - - if profile is None: - if not federee or not tenant_name: - click.secho( - "❌ Either 'profile' must be provided or both 'federee' and 'tenant_name'.", - fg="red", - bold=True, - ) - raise click.Abort() - profile = _resolve_profile(profile, federee, tenant_name) - - cfg = ConfigParser() - cfg.read(profiles_file_path) - - # Case 1: file missing or empty - if not os.path.exists(profiles_file_path) or not cfg.sections(): - click.secho( - "❌ No profiles found.", - fg="red", - bold=True, - ) - click.secho( - f"Searched in: {profiles_file_path}", - fg="cyan", - ) - click.secho( - "Please run 'ewc login' first to create a profile.", - fg="yellow", - ) - raise click.Abort() - - default_profile = ewc_hub_config.EWC_CLI_DEFAULT_PROFILE_NAME - # Case 2: requested profile missing - if profile and profile not in cfg: - if profile != default_profile: - click.secho( - f"❌ Profile '{profile}' not found.", - fg="red", - bold=True, - ) - click.secho( - f"Searched in: {profiles_file_path}", - fg="cyan", - ) - if cfg.sections(): - click.secho( - f"â„šī¸ The {profile} profile does not exist, but other profiles are available:", - fg="yellow", - ) - for name in cfg.sections(): - click.secho(f" â€ĸ {name}", fg="green") - - click.secho( - "You can either:", - fg="yellow", - ) - if default_profile in cfg: - click.secho( - " â€ĸ Use the default without --profile", - fg="cyan", - ) - click.secho( - " â€ĸ Use one of the existing profiles with --profile ", - fg="cyan", - ) - click.secho( - " â€ĸ Or run 'ewc login' to create a new profile", - fg="cyan", - ) - - # Case 3: default profile missing but others exist - if profile == default_profile and default_profile not in cfg and cfg.sections(): - click.secho( - "â„šī¸ The default profile does not exist, but other profiles are available:", - fg="yellow", - ) - for name in cfg.sections(): - click.secho(f" â€ĸ {name}", fg="green") - - click.secho( - "You can either:", - fg="yellow", - ) - click.secho( - " â€ĸ Use one of the existing profiles with --profile ", - fg="cyan", - ) - click.secho( - " â€ĸ Or run 'ewc login' to create the default profile automatically", - fg="cyan", - ) - - raise click.Abort() - - section = cfg[profile] - - federee = section.get("federee") - - allowed_federees = [f for f in ewc_hub_config.EWC_CLI_SITE_MAP] - if federee not in allowed_federees: - raise ClickException( - f"`{federee}` federee not supported. Check your profiles in ~/.ewccli/profiles. Please use one from the following: {allowed_federees}" - ) - - # Check if SSH keys path exist - ssh_public_key_path = section.get("ssh_public_key_path") - - if not ssh_public_key_path: - raise ClickException( - f"`ssh_public_key_path` key is missing from your profile {profile}, please rerun ewc login." - ) - - ssh_private_key_path = section.get("ssh_private_key_path") - - if not ssh_private_key_path: - raise ClickException( - f"`ssh_private_key_path` key is missing from your profile {profile}, please rerun ewc login." - ) - - - return { - "profile": profile, - "federee": federee, - "tenant_name": section.get("tenant_name"), - "ssh_public_key_path": ssh_public_key_path, - "ssh_private_key_path": ssh_private_key_path, - "region": section.get("region"), - "token": section.get("token"), - "application_credential_id": section.get("application_credential_id"), - "application_credential_secret": section.get("application_credential_secret"), - } - - -def generate_random_id(length: int = 10): +def generate_random_id(length: int = 10) -> str: """Generate random ID.""" characters = string.ascii_letters + string.digits random_part = "".join(secrets.choice(characters) for _ in range(length)) @@ -358,7 +35,7 @@ def run_command_from_host( command: List[str], timeout: Optional[int] = None, cwd: Optional[str] = None, - env: Optional[dict] = None, + env: Optional[dict[str, str]] = None, dry_run: bool = False, ) -> Tuple[int, str]: """Run command with subprocess.""" @@ -391,57 +68,7 @@ def run_command_from_host( return e.returncode, error_message -def run_command_from_host_live( - description: str, - command: str, - timeout: Optional[str] = None, - cwd: Optional[str] = None, - env: Optional[dict] = None, - dry_run: bool = False, -): - """Run a shell command, streaming output live to the terminal.""" - _LOGGER.info( - '"%s" -> exec command "%s" with timeout %s', description, command, timeout - ) - - if dry_run: - return 0, "Dry run. No actions." - - try: - process = subprocess.Popen( - command, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, # for automatic decoding (Python 3.7+) - bufsize=1, # line-buffered - cwd=cwd, - env=env, - shell=True, - ) - except Exception as e: - return 1, f"Failed to start process: {e}" - - def read_first_line(file: Optional[IO[str]]) -> Optional[str]: - if file is None: - return None - return file.readline() - - try: - while True: - line = read_first_line(process.stdout) - if line == "" and process.poll() is not None: - break - if line: - _LOGGER.info(line, end="") - - return process.wait(), "Finishes successfully" - - except Exception as e: - process.kill() - return 1, f"\nError running command: {e}" - - -def download_items(force: bool = False): +def download_items(force: bool = False) -> None: """Download items for the community hub.""" # URL of the YAML file url = ewc_hub_config.EWC_CLI_HUB_ITEMS_URL @@ -469,214 +96,8 @@ def download_items(force: bool = False): response.raise_for_status() item_file.write_text(response.text) _LOGGER.debug(f"Downloaded to: {item_file}") + return except requests.Timeout: _LOGGER.error("âš ī¸ Request timed out.") except requests.RequestException as e: _LOGGER.error(f"❌ Failed to download file: {e}") - - -def load_ssh_private_key(encoded_key: Optional[str] = None): - """Load SSH private key""" - if encoded_key is None: - _LOGGER.error("EWC_CLI_ENCODED_SSH_PRIVATE_KEY environment variable not set.") - sys.exit(1) - - try: - private_key = base64.b64decode(encoded_key).decode("utf-8") - return private_key - except Exception as e: - _LOGGER.error(f"Error decoding private key: {e}") - return None - - -def load_ssh_public_key(encoded_key: Optional[str] = None): - """Load SSH public key from a base64-encoded string.""" - if encoded_key is None: - _LOGGER.error("EWC_CLI_ENCODED_SSH_PUBLIC_KEY environment variable not set.") - sys.exit(1) - - try: - public_key = base64.b64decode(encoded_key).decode("utf-8") - return public_key - except Exception as e: - _LOGGER.error(f"Error decoding public key: {e}") - return None - - -def verify_private_key(private_key: str): - """Verify SSH private key using cryptography.""" - error = False - try: - key_bytes = private_key.encode("utf-8") - serialization.load_pem_private_key( - key_bytes, - password=None, # If supporting encrypted keys, provide a password - backend=default_backend(), - ) - _LOGGER.info("✅ Private key is valid.") - except ValueError as e: - _LOGGER.error(f"❌ Invalid SSH key (ValueError): {e}") - error = True - except TypeError as e: - _LOGGER.error(f"❌ SSH key error (TypeError): {e}") - error = True - except Exception as e: - _LOGGER.error(f"❌ Unexpected error while verifying SSH key: {e}") - error = True - if error: - sys.exit(1) - - -def check_ssh_keys_match(ssh_private_key_path: str, ssh_public_key_path: str) -> bool: - """ - Check whether an SSH private key corresponds to a given public key. - - Supports PEM and OpenSSH private key formats and common SSH algorithms - such as RSA, ECDSA, and Ed25519. - - Args: - ssh_private_key_path: Path to the SSH private key file. - ssh_public_key_path: Path to the SSH public key file (.pub). - - Returns: - True if the public key matches the private key. - - Raises: - ValueError: If the private key format or algorithm is unsupported. - """ - # Ensure files exist - for p in [ssh_private_key_path, ssh_public_key_path]: - if not Path(p).expanduser().is_file(): - raise ValueError(f"SSH key file does not exist: {p}") - - with open(ssh_private_key_path, "rb") as f: - private_data = f.read() - - private_key = None - - try: - private_key = serialization.load_pem_private_key(private_data, password=None) - except ValueError: - try: - private_key = serialization.load_ssh_private_key(private_data, password=None) - except ValueError: - raise ValueError("Unsupported or invalid private key format") - - derived_public = private_key.public_key().public_bytes( - encoding=serialization.Encoding.OpenSSH, - format=serialization.PublicFormat.OpenSSH - ) - - # Read provided public key and strip comment - with open(ssh_public_key_path, "r") as f: - parts = f.read().strip().split() - if len(parts) < 2: - raise ValueError(f"Invalid public key format: {ssh_public_key_path}") - provided_public = " ".join(parts[:2]).encode() - - return derived_public == provided_public - - -def save_ssh_key(ssh_key, path_key): - """Store SSH key to the provided path.""" - # Define the file path to save the key - key_path = os.path.expanduser(path_key) - - # Ensure the .ssh directory exists - os.makedirs(os.path.dirname(key_path), exist_ok=True) - - # Write the private key to the file with secure permissions - with open(key_path, "w") as key_file: - key_file.write(ssh_key) - - # Set file permissions to 0600 (owner read/write only) - os.chmod(key_path, 0o600) - - _LOGGER.debug( - f"Key saved temporarely into the container to {key_path} with 0600 permissions." - ) - - -def save_encoded_ssh_keys( - ssh_public_key_path: str, - ssh_private_key_path: str, - ssh_public_encoded: Optional[str] = None, - ssh_private_encoded: Optional[str] = None, -): - """Store SSH keys provided as encoded strings.""" - public_written = False - private_written = False - - # PUBLIC KEY - if ssh_public_encoded: - _LOGGER.info("Using encoded public key provided.") - public_key = load_ssh_public_key(encoded_key=ssh_public_encoded) - - if public_key is not None: - ssh_public_key_path.parent.mkdir(parents=True, exist_ok=True) - save_ssh_key( - ssh_key=public_key, path_key=ssh_public_key_path - ) - public_written = True - - # PRIVATE KEY - if ssh_private_encoded: - _LOGGER.info("Using encoded private key provided.") - private_key = load_ssh_private_key(encoded_key=ssh_private_encoded) - - if private_key is not None: - ssh_private_key_path.parent.mkdir(parents=True, exist_ok=True) - verify_private_key(private_key=private_key) - save_ssh_key( - ssh_key=private_key, path_key=ssh_private_key_path - ) - private_written = True - - return public_written, private_written - - -def generate_ssh_keypair( - resolved_profile: str -) -> Tuple[str, str]: - """Generate RSA SSH Key Pair and save to ~/.ssh""" - private_key = rsa.generate_private_key( - public_exponent=65537, key_size=2048, backend=default_backend() - ) - - private_key_pem = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - ) - - public_key = private_key.public_key() - public_key_ssh = public_key.public_bytes( - encoding=serialization.Encoding.OpenSSH, - format=serialization.PublicFormat.OpenSSH, - ) - - # Ensure parent directories exist - Path(ewc_hub_config.EWC_CLI_HUB_SSH_REPO_PATH).mkdir(parents=True, exist_ok=True) - - ssh_private_key_path = ewc_hub_config.EWC_CLI_HUB_SSH_REPO_PATH / f"{resolved_profile}_id_rsa" - - # Save private key - with open(ssh_private_key_path, "wb") as f: - f.write(private_key_pem) - - # Restrict permissions to owner only - os.chmod(ssh_private_key_path, 0o600) - - ssh_public_key_path = ewc_hub_config.EWC_CLI_HUB_SSH_REPO_PATH / f"{resolved_profile}_id_rsa.pub" - # Save public key - with open(ssh_public_key_path, "wb") as f: - f.write(public_key_ssh) - - # Public key can be world-readable - os.chmod(ssh_public_key_path, 0o644) - - _LOGGER.info( - f"SSH key pair generated at {ssh_private_key_path} and {ssh_public_key_path}" - ) - - return ssh_private_key_path.as_posix(), ssh_public_key_path.as_posix() diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..26f2fc4 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,6 @@ +[mypy] +ignore_missing_imports = True +warn_unused_ignores = True + +[mypy-ewccli.*] +strict = True