From 41ca27b07d75f176c6caff9c22a9275adeb44371 Mon Sep 17 00:00:00 2001 From: srnbckr Date: Tue, 9 Jun 2026 15:48:29 +0200 Subject: [PATCH] feat(nodes): support description on node import Allow users to set an optional description when importing a self-managed node, in both the interactive flow and the manual import-ssh command. - interactive: prompt 'Description (optional):' just before confirmation; empty input is normalized to None - manual: new --description/-d flag on 'nodes import-ssh' - thread description through the request, params, service and SDK gateway - add description to BaseNode domain model and map it from SDK node responses; render it (as N/A when empty) in the node detail view - test that the description is forwarded to the gateway parameters --- exls/nodes/adapters/gateway/sdk/sdk.py | 3 ++ exls/nodes/adapters/ui/display/render.py | 3 ++ exls/nodes/adapters/ui/flows/node_import.py | 7 +++++ exls/nodes/app.py | 7 +++++ exls/nodes/core/domain.py | 3 ++ exls/nodes/core/ports/operations.py | 7 ++++- exls/nodes/core/requests.py | 3 ++ exls/nodes/core/service.py | 1 + tests/unit/nodes/test_nodes_service.py | 32 +++++++++++++++++++++ 9 files changed, 65 insertions(+), 1 deletion(-) diff --git a/exls/nodes/adapters/gateway/sdk/sdk.py b/exls/nodes/adapters/gateway/sdk/sdk.py index a4d7111..2926d62 100644 --- a/exls/nodes/adapters/gateway/sdk/sdk.py +++ b/exls/nodes/adapters/gateway/sdk/sdk.py @@ -87,6 +87,7 @@ def _(sdk_model: SdkCloudNode) -> CloudNode: instance_type=sdk_model.instance_type, price_per_hour=sdk_model.price_per_hour, resources=_map_node_resources_from_sdk_model(sdk_model), + description=sdk_model.description, warning_message=sdk_model.warning_message, ) @@ -104,6 +105,7 @@ def _(sdk_model: SdkSelfManagedNode) -> SelfManagedNode: username=sdk_model.username, price_per_hour=sdk_model.price_per_hour, resources=_map_node_resources_from_sdk_model(sdk_model), + description=sdk_model.description, warning_message=sdk_model.warning_message, ) @@ -154,6 +156,7 @@ def import_selfmanaged_node( username=parameters.username, ssh_key_id=parameters.ssh_key_id, price_per_hour=parameters.price_per_hour, + description=parameters.description, ) cmd_node_import_ssh: ImportSSHNodeSdkCommand = ImportSSHNodeSdkCommand( self._nodes_api, diff --git a/exls/nodes/adapters/ui/display/render.py b/exls/nodes/adapters/ui/display/render.py index 8de9d04..c251914 100644 --- a/exls/nodes/adapters/ui/display/render.py +++ b/exls/nodes/adapters/ui/display/render.py @@ -52,6 +52,9 @@ "Import Time", value_formatter=format_datetime ), "status": TableRenderContext.get_column("Status", value_formatter=format_status), + "description": TableRenderContext.get_column( + "Description", value_formatter=format_na + ), "warning_message": TableRenderContext.get_column("Warning", hide_if_empty=True), "price_per_hour": TableRenderContext.get_column("Price", value_formatter=format_na), # Hardware Resources diff --git a/exls/nodes/adapters/ui/flows/node_import.py b/exls/nodes/adapters/ui/flows/node_import.py index 55da027..a9245b9 100644 --- a/exls/nodes/adapters/ui/flows/node_import.py +++ b/exls/nodes/adapters/ui/flows/node_import.py @@ -52,6 +52,7 @@ class FlowSelfmanagedNodeSpecificationDTO(BaseModel): ssh_key: Optional[Union[NodeSshKey, FlowNodesSshKeySpecification]] = Field( default=None, description="The SSH key to use" ) + description: StrictStr = Field(default="", description="Description of the node") def to_domain(self) -> ImportSelfmanagedNodeRequest: ssh_key_val: Union[str, NodesSshKeySpecification] @@ -71,6 +72,7 @@ def to_domain(self) -> ImportSelfmanagedNodeRequest: username=self.username, price_per_hour=self.price_per_hour, ssh_key=ssh_key_val, + description=self.description or None, ) @@ -205,6 +207,11 @@ def execute( ), # We could allow to cancel the subflow of ssh key import but we would need # to detect the cancellation and jump to a previous step. + TextInputStep[FlowSelfmanagedNodeSpecificationDTO]( + key="description", + message="Description (optional):", + default="", + ), ] ) diff --git a/exls/nodes/app.py b/exls/nodes/app.py index 0cb4f38..3b847d4 100644 --- a/exls/nodes/app.py +++ b/exls/nodes/app.py @@ -250,6 +250,12 @@ def import_selfmanaged_node( "--ssh-key-name", help="The name of the SSH key to import", ), + description: Optional[str] = typer.Option( + None, + "--description", + "-d", + help="An optional description for the node to import", + ), ): """Import a self-managed node into the node pool.""" bundle: NodesBundle = _get_bundle(ctx) @@ -281,6 +287,7 @@ def import_selfmanaged_node( username=username, price_per_hour=price_per_hour, ssh_key=final_ssh_key, + description=description, ) ] ) diff --git a/exls/nodes/core/domain.py b/exls/nodes/core/domain.py index 5444c1c..cee8279 100644 --- a/exls/nodes/core/domain.py +++ b/exls/nodes/core/domain.py @@ -45,6 +45,9 @@ class BaseNode(BaseModel): price_per_hour: NonNegativeFloat = Field( ..., description="The price per hour of the node" ) + description: Optional[StrictStr] = Field( + default=None, description="Description of the node" + ) warning_message: Optional[StrictStr] = Field( default=None, description="Warning message if node is in WARNING status" ) diff --git a/exls/nodes/core/ports/operations.py b/exls/nodes/core/ports/operations.py index 441d46d..265adcb 100644 --- a/exls/nodes/core/ports/operations.py +++ b/exls/nodes/core/ports/operations.py @@ -1,7 +1,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import List +from typing import List, Optional from pydantic import BaseModel, Field, NonNegativeFloat, StrictStr @@ -21,6 +21,9 @@ class ImportSelfmanagedNodeParameters(BaseModel): price_per_hour: NonNegativeFloat = Field( ..., description="The price per hour to use" ) + description: Optional[StrictStr] = Field( + default=None, description="Description of the node" + ) @classmethod def from_request( @@ -33,6 +36,7 @@ def from_request( username=request.username, ssh_key_id=request.ssh_key, price_per_hour=request.price_per_hour, + description=request.description, ) @classmethod @@ -45,6 +49,7 @@ def to_request( username=parameters.username, ssh_key=parameters.ssh_key_id, price_per_hour=parameters.price_per_hour, + description=parameters.description, ) diff --git a/exls/nodes/core/requests.py b/exls/nodes/core/requests.py index 2dc7a1f..756479e 100644 --- a/exls/nodes/core/requests.py +++ b/exls/nodes/core/requests.py @@ -45,6 +45,9 @@ class ImportSelfmanagedNodeRequest(BaseModel): ssh_key: Union[StrictStr, NodesSshKeySpecification] = Field( ..., description="The SSH key to use" ) + description: Optional[StrictStr] = Field( + default=None, description="Description of the node" + ) class ImportCloudNodeRequest(BaseModel): diff --git a/exls/nodes/core/service.py b/exls/nodes/core/service.py index f5290d1..ca3cf4e 100644 --- a/exls/nodes/core/service.py +++ b/exls/nodes/core/service.py @@ -250,6 +250,7 @@ def _import_single_ssh_key(spec: NodesSshKeySpecification) -> NodeSshKey: username=node_import_request.username, ssh_key_id=ssh_key_id, price_per_hour=node_import_request.price_per_hour, + description=node_import_request.description, ) ) diff --git a/tests/unit/nodes/test_nodes_service.py b/tests/unit/nodes/test_nodes_service.py index 3386085..f560da8 100644 --- a/tests/unit/nodes/test_nodes_service.py +++ b/tests/unit/nodes/test_nodes_service.py @@ -278,6 +278,38 @@ def test_import_selfmanaged_nodes_success( assert result.imported_nodes[0].id == "node-1" assert len(result.issues) == 0 + def test_import_selfmanaged_nodes_forwards_description( + self, + nodes_service: NodesService, + mock_nodes_operations: MagicMock, + mock_ssh_key_provider: MagicMock, + mock_nodes_repository: MagicMock, + sample_ssh_key: NodeSshKey, + sample_self_managed_node: SelfManagedNode, + ) -> None: + # Arrange + request = ImportSelfmanagedNodeRequest( + hostname="host1", + endpoint="1.2.3.4", + username="user", + ssh_key="key-1", + price_per_hour=2.1, + description="A100 box, lab 2", + ) + mock_ssh_key_provider.list_keys.return_value = [sample_ssh_key] + mock_nodes_operations.import_selfmanaged_node.return_value = "node-1" + mock_nodes_repository.get.return_value = sample_self_managed_node + + # Act + nodes_service.import_selfmanaged_nodes([request]) + + # Assert + mock_nodes_operations.import_selfmanaged_node.assert_called_once() + passed_parameters = ( + mock_nodes_operations.import_selfmanaged_node.call_args.kwargs["parameters"] + ) + assert passed_parameters.description == "A100 box, lab 2" + def test_import_selfmanaged_nodes_with_new_key( self, nodes_service: NodesService,