diff --git a/das-cli/src/commands/atomdb_broker/atomdb_broker_cli.py b/das-cli/src/commands/atomdb_broker/atomdb_broker_cli.py index 036559c8..abc5b667 100644 --- a/das-cli/src/commands/atomdb_broker/atomdb_broker_cli.py +++ b/das-cli/src/commands/atomdb_broker/atomdb_broker_cli.py @@ -1,6 +1,6 @@ from injector import inject -from common import Command, CommandGroup, CommandOption, Settings, StdoutSeverity, StdoutType +from common import Command, CommandGroup, CommandOption, Settings, StdoutSeverity from common.container_manager.busnode_container_manager import BusNodeContainerManager from common.decorators import ensure_container_running from common.docker.exceptions import ( @@ -8,8 +8,10 @@ DockerContainerNotFoundError, DockerError, ) +from common.exceptions import PortBindingError from common.factory.atomdb.atomdb_backend import AtomdbBackend from common.prompt_types import PortRangeType +from common.service_response import CONTAINER_START_FAILURE_MESSAGE, ServiceResponse, StdoutStatus from .atomdb_broker_docs import ( HELP_ATOMDB_BROKER, @@ -21,7 +23,8 @@ SHORT_HELP_START, SHORT_HELP_STOP, ) -from .atomdb_broker_service_response import AtomDbBrokerServiceReponse + +CLI_SERVICE_NAME = "atomdb_broker" class AtomDbBrokerStart(Command): @@ -59,46 +62,51 @@ def _start_container(self, port_range, **kwargs): container = self._get_container() port = container.port - self.stdout("Starting AtomDB Broker service...") + self.log("Starting AtomDB Broker service...", severity=StdoutSeverity.INFO) try: self._atomdb_broker_bus_manager.start_container(port_range, **kwargs) message = f"AtomDB Broker started on port {port}" - self.stdout(message, severity=StdoutSeverity.SUCCESS) - self.stdout( dict( - AtomDbBrokerServiceReponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="start", - status="success", + status=StdoutStatus.SUCCESS, message=message, container=self._get_container(), - ) + ), ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.SUCCESS, ) except DockerContainerDuplicateError: message = f"AtomDB Broker is already running. It's listening on port {port}" - self.stdout(message, severity=StdoutSeverity.WARNING) - self.stdout( - dict( - AtomDbBrokerServiceReponse( - action="start", - status="already_running", - message=message, - container=self._get_container(), - ) + ServiceResponse( + service=CLI_SERVICE_NAME, + action="start", + status=StdoutStatus.INFO, + message=message, + container=self._get_container(), ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.WARNING, ) - except DockerError as e: - error_message = f"Error occurred while trying to start Attention Broker on port {port}" - raise DockerError(f"{error_message}\nOriginal error: {e}") + except (DockerError, PortBindingError) as error: + self.stdout( + ServiceResponse( + service=CLI_SERVICE_NAME, + action="start", + status=StdoutStatus.ERROR, + message=CONTAINER_START_FAILURE_MESSAGE, + error=error, + container=self._get_container(), + ), + severity=StdoutSeverity.ERROR, + ) @ensure_container_running( [ @@ -132,42 +140,40 @@ def _get_container(self): def _stop_container(self): container = self._get_container() - self.stdout("Stopping AtomDB Broker service...") + self.log("Stopping AtomDB Broker service...", severity=StdoutSeverity.INFO) try: self._atomdb_broker_bus_manager.stop() exec_message = "AtomDB Broker service stopped" - self.stdout(exec_message, severity=StdoutSeverity.SUCCESS) - self.stdout( dict( - AtomDbBrokerServiceReponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="stop", - status="already_stopped", + status=StdoutStatus.SUCCESS, message=exec_message, container=container, - ) + ), ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.SUCCESS, ) except DockerContainerNotFoundError: container_name = self._get_container().name message = f"The AtomDB Broker service named {container_name} is already stopped." - self.stdout(message, severity=StdoutSeverity.WARNING) - self.stdout( dict( - AtomDbBrokerServiceReponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="stop", - status="already_stopped", + status=StdoutStatus.INFO, message=message, container=self._get_container(), - ) + ), ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.WARNING, ) def run(self): @@ -187,9 +193,9 @@ class AtomDbBrokerRestart(Command): ), ] - short_help = HELP_RESTART + short_help = SHORT_HELP_RESTART - help = SHORT_HELP_RESTART + help = HELP_RESTART @inject def __init__( @@ -200,8 +206,8 @@ def __init__( super().__init__() def run(self, port_range, **kwargs): - self._atomdb_broker_stop.run() - self._atomdb_broker_start.run(port_range, **kwargs) + self.run_subcommand(self._atomdb_broker_stop) + self.run_subcommand(self._atomdb_broker_start, port_range, **kwargs) class AtomDbBrokerCli(CommandGroup): diff --git a/das-cli/src/commands/atomdb_broker/atomdb_broker_service_response.py b/das-cli/src/commands/atomdb_broker/atomdb_broker_service_response.py deleted file mode 100644 index f07e821b..00000000 --- a/das-cli/src/commands/atomdb_broker/atomdb_broker_service_response.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Optional - -from common.docker.container_manager import Container -from common.service_response import ServiceResponse - - -class AtomDbBrokerServiceReponse(ServiceResponse): - def __init__( - self, - action: str, - status: str, - message: str, - container: Optional[Container] = None, - extra_details: Optional[dict] = None, - error: Optional[dict] = None, - ): - super().__init__( - service="atomdb_broker", - action=action, - status=status, - message=message, - container=container, - error=error, - **(extra_details or {}), - ) diff --git a/das-cli/src/commands/attention_broker/attention_broker_cli.py b/das-cli/src/commands/attention_broker/attention_broker_cli.py index a819a22f..45f6d200 100644 --- a/das-cli/src/commands/attention_broker/attention_broker_cli.py +++ b/das-cli/src/commands/attention_broker/attention_broker_cli.py @@ -1,6 +1,6 @@ from injector import inject -from common import Command, CommandGroup, Settings, StdoutSeverity, StdoutType +from common import Command, CommandGroup, Settings, StdoutSeverity from common.container_manager.agents.attention_broker_container_manager import ( AttentionBrokerManager, ) @@ -9,6 +9,8 @@ DockerContainerNotFoundError, DockerError, ) +from common.exceptions import PortBindingError +from common.service_response import CONTAINER_START_FAILURE_MESSAGE, ServiceResponse, StdoutStatus from .attention_broker_docs import ( HELP_ATTENTION_BROKER, @@ -20,7 +22,8 @@ SHORT_HELP_START, SHORT_HELP_STOP, ) -from .attention_broker_service_response import AttentionBrokerServiceResponse + +CLI_SERVICE_NAME = "attention_broker" class AttentionBrokerStop(Command): @@ -44,47 +47,40 @@ def _get_container(self): return self._attention_broker_manager.get_container() def _attention_broker(self): + self.log("Stopping Attention Broker service...", severity=StdoutSeverity.INFO) + try: - self.stdout("Stopping Attention Broker service...") self._attention_broker_manager.stop() - - success_message = "Attention Broker service stopped" + exec_message = "Attention Broker service stopped" - self.stdout( - success_message, - severity=StdoutSeverity.SUCCESS, - ) self.stdout( dict( - AttentionBrokerServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="stop", - status="success", - message=success_message, + status=StdoutStatus.SUCCESS, + message=exec_message, container=self._get_container(), - ) + ), ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.SUCCESS, ) except DockerContainerNotFoundError: container_name = self._attention_broker_manager.get_container().name - warning_message = ( - f"The Attention Broker service named {container_name} is already stopped." - ) - self.stdout( - warning_message, - severity=StdoutSeverity.WARNING, - ) + message = f"The Attention Broker service named {container_name} is already stopped." + self.stdout( dict( - AttentionBrokerServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="stop", - status="already_stopped", - message=warning_message, + status=StdoutStatus.INFO, + message=message, container=self._get_container(), - ) + ), ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.WARNING, ) def run(self): @@ -113,52 +109,54 @@ def _get_container(self): return self._attention_broker_container_manager.get_container() def _attention_broker(self) -> None: - self.stdout("Starting Attention Broker service...") - container = self._attention_broker_container_manager.get_container() port = container.port + self.log("Starting Attention Broker service...", severity=StdoutSeverity.INFO) + try: self._attention_broker_container_manager.start_container() - - success_message = f"Attention Broker started on port {port}" + message = f"Attention Broker started on port {port}" - self.stdout( - success_message, - severity=StdoutSeverity.SUCCESS, - ) self.stdout( dict( - AttentionBrokerServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="start", - status="success", - message=success_message, + status=StdoutStatus.SUCCESS, + message=message, container=container, - ) + ), ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.SUCCESS, ) + except DockerContainerDuplicateError: - warning_message = f"Attention Broker is already running. It's listening on port {port}" + message = f"Attention Broker is already running. It's listening on port {port}" self.stdout( - warning_message, + ServiceResponse( + service=CLI_SERVICE_NAME, + action="start", + status=StdoutStatus.INFO, + message=message, + container=container, + ), severity=StdoutSeverity.WARNING, ) + + except (DockerError, PortBindingError) as e: self.stdout( - dict( - AttentionBrokerServiceResponse( - action="start", - status="already_running", - message=warning_message, - container=container, - ) + ServiceResponse( + service=CLI_SERVICE_NAME, + action="start", + status=StdoutStatus.ERROR, + message=CONTAINER_START_FAILURE_MESSAGE, + error=e, + container=container, ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.ERROR, ) - except DockerError as e: - error_message = f"Error occurred while trying to start Attention Broker on port {port}" - raise DockerError(f"{error_message}\nOriginal error: {e}") def run(self): self._settings.validate_configuration_file() @@ -183,8 +181,8 @@ def __init__( self._attention_broker_stop = attention_broker_stop def run(self): - self._attention_broker_stop.run() - self._attention_broker_start.run() + self.run_subcommand(self._attention_broker_stop) + self.run_subcommand(self._attention_broker_start) class AttentionBrokerCli(CommandGroup): diff --git a/das-cli/src/commands/attention_broker/attention_broker_service_response.py b/das-cli/src/commands/attention_broker/attention_broker_service_response.py deleted file mode 100644 index e312c35d..00000000 --- a/das-cli/src/commands/attention_broker/attention_broker_service_response.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Optional - -from common.docker.container_manager import Container -from common.service_response import ServiceResponse - - -class AttentionBrokerServiceResponse(ServiceResponse): - def __init__( - self, - action: str, - status: str, - message: str, - container: Optional[Container] = None, - extra_details: Optional[dict] = None, - error: Optional[dict] = None, - ): - super().__init__( - service="attention_broker", - action=action, - status=status, - message=message, - container=container, - error=error, - **(extra_details or {}), - ) diff --git a/das-cli/src/commands/command_router/command_router_cli.py b/das-cli/src/commands/command_router/command_router_cli.py index bcaa66ec..7f178d25 100644 --- a/das-cli/src/commands/command_router/command_router_cli.py +++ b/das-cli/src/commands/command_router/command_router_cli.py @@ -1,13 +1,6 @@ from injector import inject -from common import ( - Command, - CommandGroup, - CommandOption, - Settings, - StdoutSeverity, - StdoutType, -) +from common import Command, CommandGroup, CommandOption, Settings, StdoutSeverity from common.container_manager.busnode_container_manager import ( BusNodeContainerManager, ) @@ -17,8 +10,10 @@ DockerContainerNotFoundError, DockerError, ) +from common.exceptions import PortBindingError from common.factory.atomdb.atomdb_backend import AtomdbBackend from common.prompt_types import PortRangeType +from common.service_response import CONTAINER_START_FAILURE_MESSAGE, ServiceResponse, StdoutStatus from .command_router_docs import ( HELP_COMMAND_ROUTER, @@ -30,7 +25,8 @@ SHORT_HELP_START, SHORT_HELP_STOP, ) -from .command_router_service_response import CommandRouterServiceResponse + +CLI_SERVICE_NAME = "command_router" class CommandRouterStart(Command): @@ -67,49 +63,50 @@ def _start_container(self, port_range): container = self._get_container() port = container.port - self.stdout("Starting Command Router service...") + self.log("Starting Command Router service...", severity=StdoutSeverity.INFO) try: self._command_router_container_manager.start_container(ports_range=port_range) - message = f"Command Router started on port {port}" - self.stdout(message, severity=StdoutSeverity.SUCCESS) - self.stdout( dict( - CommandRouterServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="start", - status="success", + status=StdoutStatus.SUCCESS, message=message, container=self._get_container(), ) ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.SUCCESS, ) except DockerContainerDuplicateError: - message = f"Command Router is already running. " f"It's listening on port {port}" - - self.stdout(message, severity=StdoutSeverity.WARNING) + message = f"Command Router is already running. It's listening on port {port}" self.stdout( - dict( - CommandRouterServiceResponse( - action="start", - status="already_running", - message=message, - container=self._get_container(), - ) + ServiceResponse( + service=CLI_SERVICE_NAME, + action="start", + status=StdoutStatus.INFO, + message=message, + container=self._get_container(), ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.WARNING, ) - except DockerError as e: - raise DockerError( - f"Error occurred while trying to start " - f"Command Router on port {port}\n" - f"Original error: {e}" + except (DockerError, PortBindingError) as e: + self.stdout( + ServiceResponse( + service=CLI_SERVICE_NAME, + action="start", + status=StdoutStatus.ERROR, + message=CONTAINER_START_FAILURE_MESSAGE, + error=e, + container=self._get_container(), + ), + severity=StdoutSeverity.ERROR, ) @ensure_container_running( @@ -146,50 +143,40 @@ def _get_container(self): def _stop_container(self): container = self._get_container() - self.stdout("Stopping Command Router service...") + self.log("Stopping Command Router service...", severity=StdoutSeverity.INFO) try: self._command_router_container_manager.stop() - - message = "Command Router service stopped" - - self.stdout( - message, - severity=StdoutSeverity.SUCCESS, - ) + exec_message = "Command Router service stopped" self.stdout( dict( - CommandRouterServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="stop", - status="success", - message=message, + status=StdoutStatus.SUCCESS, + message=exec_message, container=container, - ) + ), ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.SUCCESS, ) except DockerContainerNotFoundError: container_name = self._get_container().name - - message = f"The Command Router service named " f"{container_name} is already stopped." - - self.stdout( - message, - severity=StdoutSeverity.WARNING, - ) + message = f"The Command Router service named {container_name} is already stopped." self.stdout( dict( - CommandRouterServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="stop", - status="already_stopped", + status=StdoutStatus.INFO, message=message, container=self._get_container(), - ) + ), ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.WARNING, ) def run(self): @@ -220,8 +207,8 @@ def __init__( super().__init__() def run(self, port_range): - self.command_router_stop.run() - self.command_router_start.run(port_range=port_range) + self.run_subcommand(self.command_router_stop) + self.run_subcommand(self.command_router_start, port_range=port_range) class CommandRouterCli(CommandGroup): diff --git a/das-cli/src/commands/command_router/command_router_service_response.py b/das-cli/src/commands/command_router/command_router_service_response.py deleted file mode 100644 index 94f82dda..00000000 --- a/das-cli/src/commands/command_router/command_router_service_response.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Optional - -from common.docker.container_manager import Container -from common.service_response import ServiceResponse - - -class CommandRouterServiceResponse(ServiceResponse): - def __init__( - self, - action: str, - status: str, - message: str, - container: Optional[Container] = None, - extra_details: Optional[dict] = None, - error: Optional[dict] = None, - ): - super().__init__( - service="command_router", - action=action, - status=status, - message=message, - container=container, - error=error, - **(extra_details or {}), - ) diff --git a/das-cli/src/commands/config/config_cli.py b/das-cli/src/commands/config/config_cli.py index 25c3d193..38220b8c 100644 --- a/das-cli/src/commands/config/config_cli.py +++ b/das-cli/src/commands/config/config_cli.py @@ -9,9 +9,10 @@ CommandOption, KeyValueType, RemoteContextManager, + ServiceResponse, Settings, StdoutSeverity, - StdoutType, + StdoutStatus, ) from common.prompt_types import AbsolutePath from settings.config import CURRENT_CONFIGFILE_PATH @@ -27,6 +28,8 @@ from .config_provider import InteractiveConfigProvider, NonInteractiveConfigProvider from .config_sections.normalize_file import verify_populate_missing_values +CLI_SERVICE_NAME = "config" + class ConfigSet(Command): name = "set" @@ -71,6 +74,21 @@ def __init__( self._non_interactive_config_provider = non_interactive_config_provider self._interactive_config_provider = interactive_config_provider + def _finish_set(self, message: str) -> None: + self.stdout( + dict( + ServiceResponse( + service=CLI_SERVICE_NAME, + action="set", + status=StdoutStatus.SUCCESS, + message=message, + path=self._settings.get_path(), + content=self._settings.get_content(), + ) + ), + severity=StdoutSeverity.SUCCESS, + ) + def _set_file_path(self, save_path) -> None: self._settings.set_path(save_path) self._settings.rewind() @@ -89,17 +107,19 @@ def _set_file_path(self, save_path) -> None: verify_populate_missing_values(self._settings, save_path) - self.stdout( + self.log( "Formatting file and setting up incorrect/incomplete values.", severity=StdoutSeverity.WARNING, ) self._settings.save_path() - self.stdout( - f"Configuration file set to -> {self._settings.get_path()}", + config_path = self._settings.get_path() + self.log( + f"Configuration file set to -> {config_path}", severity=StdoutSeverity.SUCCESS, ) + self._finish_set(f"Configuration file set to {config_path}.") def _save(self, save_path: str) -> None: self._remote_context_manager.commit() @@ -107,10 +127,12 @@ def _save(self, save_path: str) -> None: self._settings.save() self._settings.save_path() - self.stdout( - f"Configuration file saved -> {self._settings.get_path()}", + config_path = self._settings.get_path() + self.log( + f"Configuration file saved -> {config_path}", severity=StdoutSeverity.SUCCESS, ) + self._finish_set(f"Configuration file saved to {config_path}.") def interactive_mode(self) -> None: config_mappings = self._interactive_config_provider.setup_settings() @@ -172,21 +194,52 @@ def _show_config_key(self, key: str) -> None: value = self._settings.get(key, None) if value is None: self.stdout( - f"The key '{key}' does not exist in the configuration file.", + dict( + ServiceResponse( + service=CLI_SERVICE_NAME, + action="list", + status=StdoutStatus.ERROR, + message=f"The key '{key}' does not exist in the configuration file.", + key=key, + ) + ), severity=StdoutSeverity.ERROR, ) - else: - self.stdout(value) - self.stdout( - value, - stdout_type=StdoutType.MACHINE_READABLE, - ) + return + + self.log(str(value), severity=StdoutSeverity.INFO) + self.stdout( + dict( + ServiceResponse( + service=CLI_SERVICE_NAME, + action="list", + status=StdoutStatus.SUCCESS, + message=f"Configuration key '{key}' listed successfully.", + path=self._settings.get_path(), + key=key, + value=value, + ) + ), + severity=StdoutSeverity.SUCCESS, + ) def _show_config(self) -> None: - self.stdout(self._settings.pretty()) + content = self._settings.get_content() + config_path = self._settings.get_path() + + self.log(self._settings.pretty(), severity=StdoutSeverity.INFO) self.stdout( - self._settings.get_content(), - stdout_type=StdoutType.MACHINE_READABLE, + dict( + ServiceResponse( + service=CLI_SERVICE_NAME, + action="list", + status=StdoutStatus.SUCCESS, + message="Configuration listed successfully.", + path=config_path, + content=content, + ) + ), + severity=StdoutSeverity.SUCCESS, ) def run(self, key: Optional[str] = None): diff --git a/das-cli/src/commands/context_broker/context_broker_cli.py b/das-cli/src/commands/context_broker/context_broker_cli.py index d8c4ee36..113ed3c5 100644 --- a/das-cli/src/commands/context_broker/context_broker_cli.py +++ b/das-cli/src/commands/context_broker/context_broker_cli.py @@ -1,6 +1,6 @@ from injector import inject -from common import Command, CommandGroup, CommandOption, Settings, StdoutSeverity, StdoutType +from common import Command, CommandGroup, CommandOption, Settings, StdoutSeverity from common.container_manager.agents.generic_agent_containers import QueryAgentContainerManager from common.container_manager.busnode_container_manager import BusNodeContainerManager from common.decorators import ensure_container_running @@ -9,9 +9,10 @@ DockerContainerNotFoundError, DockerError, ) +from common.exceptions import PortBindingError from common.prompt_types import PortRangeType +from common.service_response import CONTAINER_START_FAILURE_MESSAGE, ServiceResponse, StdoutStatus -from .context_broker_container_service_response import ContextBrokerContainerServiceResponse from .context_broker_docs import ( HELP_CONTEXT_BROKER, HELP_RESTART, @@ -23,6 +24,8 @@ SHORT_HELP_STOP, ) +CLI_SERVICE_NAME = "context_broker" + class ContextBrokerStop(Command): name = "stop" @@ -43,45 +46,39 @@ def _get_container(self): return self._context_broker_bus_node_manager.get_container() def _context_broker(self): + self.log("Stopping Context Broker service...", severity=StdoutSeverity.INFO) + try: - self.stdout("Stopping Context Broker service...") self._context_broker_bus_node_manager.stop() + exec_message = "Context Broker service stopped" - success_message = "Context Broker service stopped" - self.stdout( - success_message, - severity=StdoutSeverity.SUCCESS, - ) self.stdout( dict( - ContextBrokerContainerServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="stop", - status="success", - message=success_message, + status=StdoutStatus.SUCCESS, + message=exec_message, container=self._get_container(), ) ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.SUCCESS, ) + except DockerContainerNotFoundError: container_name = self._get_container().name - warning_message = ( - f"The Context Broker service named {container_name} is already stopped." - ) - self.stdout( - warning_message, - severity=StdoutSeverity.WARNING, - ) + message = f"The Context Broker service named {container_name} is already stopped." + self.stdout( dict( - ContextBrokerContainerServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="stop", - status="already_stopped", - message=warning_message, + status=StdoutStatus.INFO, + message=message, container=self._get_container(), ) ), - stdout_type=StdoutType.MACHINE_READABLE, severity=StdoutSeverity.WARNING, ) @@ -122,57 +119,55 @@ def _get_container(self): return self._context_broker_bus_node_manager.get_container() def _context_broker(self, port_range: str) -> None: - self.stdout("Starting Context Broker service...") - container = self._get_container() - context_broker_port = container.port + port = container.port + + self.log("Starting Context Broker service...", severity=StdoutSeverity.INFO) try: self._context_broker_bus_node_manager.start_container(port_range) - - success_message = f"Context Broker started on port {context_broker_port}" - self.stdout( - success_message, - severity=StdoutSeverity.SUCCESS, - ) + message = f"Context Broker started on port {port}" self.stdout( dict( - ContextBrokerContainerServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="start", - status="success", - message=success_message, + status=StdoutStatus.SUCCESS, + message=message, container=self._get_container(), ) ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.SUCCESS, ) + except DockerContainerDuplicateError: - warning_message = ( - f"Context Broker is already running. It's listening on port {context_broker_port}" - ) + message = f"Context Broker is already running. It's listening on port {port}" self.stdout( - warning_message, + ServiceResponse( + service=CLI_SERVICE_NAME, + action="start", + status=StdoutStatus.INFO, + message=message, + container=self._get_container(), + ), severity=StdoutSeverity.WARNING, ) + except (DockerError, PortBindingError) as e: self.stdout( - dict( - ContextBrokerContainerServiceResponse( - action="start", - status="already_running", - message=warning_message, - container=self._get_container(), - ) + ServiceResponse( + service=CLI_SERVICE_NAME, + action="start", + status=StdoutStatus.ERROR, + message=CONTAINER_START_FAILURE_MESSAGE, + error=e, + container=self._get_container(), ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.ERROR, ) - except DockerError as e: - error_message = f"Error occurred while trying to start Attention Broker on port {context_broker_port}" - raise DockerError(f"{error_message}\nOriginal error: {e}") - @ensure_container_running( [ "_query_agent_container_manager", @@ -213,8 +208,8 @@ def __init__( self._context_broker_stop = context_broker_stop def run(self, port_range: str) -> None: - self._context_broker_stop.run() - self._context_broker_start.run(port_range) + self.run_subcommand(self._context_broker_stop) + self.run_subcommand(self._context_broker_start, port_range) class ContextBrokerCli(CommandGroup): diff --git a/das-cli/src/commands/context_broker/context_broker_container_service_response.py b/das-cli/src/commands/context_broker/context_broker_container_service_response.py deleted file mode 100644 index c788f93e..00000000 --- a/das-cli/src/commands/context_broker/context_broker_container_service_response.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Optional - -from common.docker.container_manager import Container -from common.service_response import ServiceResponse - - -class ContextBrokerContainerServiceResponse(ServiceResponse): - def __init__( - self, - action: str, - status: str, - message: str, - container: Optional[Container] = None, - extra_details: Optional[dict] = None, - error: Optional[dict] = None, - ): - super().__init__( - service="context_broker", - action=action, - status=status, - message=message, - container=container, - error=error, - **(extra_details or {}), - ) diff --git a/das-cli/src/commands/database_adapter/database_adapter_service_response.py b/das-cli/src/commands/database_adapter/database_adapter_service_response.py deleted file mode 100644 index c20e705c..00000000 --- a/das-cli/src/commands/database_adapter/database_adapter_service_response.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Optional - -from common.docker.container_manager import Container -from common.service_response import ServiceResponse - - -class DatabaseAdapterServiceResponse(ServiceResponse): - def __init__( - self, - action: str, - status: str, - message: str, - container: Optional[Container] = None, - extra_details: Optional[dict] = None, - error: Optional[dict] = None, - ): - super().__init__( - service="command_router", - action=action, - status=status, - message=message, - container=container, - error=error, - **(extra_details or {}), - ) diff --git a/das-cli/src/commands/database_adapter/dbms_adapter_cli.py b/das-cli/src/commands/database_adapter/dbms_adapter_cli.py index 2ba5d3f9..e00987f3 100644 --- a/das-cli/src/commands/database_adapter/dbms_adapter_cli.py +++ b/das-cli/src/commands/database_adapter/dbms_adapter_cli.py @@ -12,6 +12,7 @@ from common.decorators import ensure_container_running from common.docker.exceptions import DockerContainerNotFoundError from common.factory.atomdb.atomdb_backend import AtomdbBackend +from common.service_response import ServiceResponse, StdoutStatus from .database_adapter_docs import ( HELP_DATABASE_ADAPTER, @@ -50,18 +51,37 @@ def __init__( def run(self): self._settings.validate_configuration_file() - self.stdout("Starting Database Adapter...") + self.log("Starting Database Adapter...", severity=StdoutSeverity.INFO) try: self._database_adapter_container_manager.start_container() self.stdout( - "Database Adapter started successfully.", + dict( + ServiceResponse( + service="database-adapter", + action="run", + status=StdoutStatus.SUCCESS, + message="Database Adapter started successfully.", + ) + ), severity=StdoutSeverity.SUCCESS, ) - except Exception as e: - raise RuntimeError(f"Failed to start Database Adapter.\n" f"Original error: {e}") + except Exception as error: + self.stdout( + dict( + ServiceResponse( + service="database-adapter", + action="run", + status=StdoutStatus.ERROR, + message="Failed to start Database Adapter.", + error=str(error), + ) + ), + severity=StdoutSeverity.ERROR, + ) + raise class DatabaseAdapterStop(Command): @@ -84,13 +104,20 @@ def __init__( def run(self): self._settings.validate_configuration_file() - self.stdout("Stopping Database Adapter...") + self.log("Stopping Database Adapter...", severity=StdoutSeverity.INFO) try: self._database_adapter_container_manager.stop() self.stdout( - "Database Adapter stopped successfully.", + dict( + ServiceResponse( + service="database-adapter", + action="stop", + status=StdoutStatus.SUCCESS, + message="Database Adapter stopped successfully.", + ) + ), severity=StdoutSeverity.SUCCESS, ) @@ -98,7 +125,17 @@ def run(self): container_name = self._database_adapter_container_manager.get_container().name self.stdout( - f"The Database Adapter service named {container_name} is already stopped.", + dict( + ServiceResponse( + service="database-adapter", + action="stop", + status=StdoutStatus.INFO, + message=( + f"The Database Adapter service named {container_name} " + "is already stopped." + ), + ) + ), severity=StdoutSeverity.WARNING, ) diff --git a/das-cli/src/commands/db/db_cli.py b/das-cli/src/commands/db/db_cli.py index f37dca7a..a327b0ba 100644 --- a/das-cli/src/commands/db/db_cli.py +++ b/das-cli/src/commands/db/db_cli.py @@ -1,20 +1,16 @@ from injector import inject -from common import Command, CommandGroup, CommandOption, Settings, StdoutSeverity, StdoutType +from common import Command, CommandGroup, CommandOption, Settings, StdoutSeverity from common.container_manager.atomdb.mongodb_container_manager import MongodbContainerManager from common.container_manager.atomdb.morkdb_container_manager import MorkdbContainerManager from common.container_manager.atomdb.redis_container_manager import RedisContainerManager from common.decorators import ensure_container_running -from common.docker.exceptions import ( - DockerContainerDuplicateError, - DockerContainerNotFoundError, - DockerError, -) from common.factory.atomdb.atomdb_backend import ( AtomdbBackend, MongoDBRedisBackend, MorkMongoDBBackend, ) +from common.service_response import ServiceResponse, StdoutStatus from .db_docs import ( HELP_DB_CLI, @@ -28,7 +24,7 @@ SHORT_HELP_DB_START, SHORT_HELP_DB_STOP, ) -from .db_service_response import DbServiceResponse +from .db_services import CLI_SERVICE_NAME, DbOperations class DbCountAtoms(Command): @@ -54,45 +50,40 @@ def __init__( def _get_mongodb_container(self): return self._mongodb_container_manager.get_container() - def _get_redis_container(self): - return self._redis_container_manager.get_container() - def _show_mongodb_stats(self): collection_stats = self._mongodb_container_manager.get_collection_stats() if len(collection_stats) < 1: - self.stdout("MongoDB: No collections found (0)") + self.log("MongoDB: No collections found (0)", severity=StdoutSeverity.WARNING) return self.stdout( dict( - DbServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="count-atoms", - status="no_collections_found", + status=StdoutStatus.INFO, message="No MongoDB collections found.", container=self._get_mongodb_container(), - extra_details={ - "stats": collection_stats, - }, + stats=collection_stats, ) ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.WARNING, ) for key, count in collection_stats.items(): - self.stdout(f"MongoDB {key}: {count}") + self.log(f"MongoDB {key}: {count}", severity=StdoutSeverity.INFO) self.stdout( dict( - DbServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="count-atoms", - status="success", + status=StdoutStatus.SUCCESS, message="Count of MongoDB atoms displayed successfully.", container=self._get_mongodb_container(), - extra_details={ - "stats": collection_stats, - }, + stats=collection_stats, ) ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.SUCCESS, ) @ensure_container_running( @@ -102,10 +93,7 @@ def _show_mongodb_stats(self): ) def run(self) -> None: for provider in self._atomdb_backend.get_active_providers(): - if isinstance(provider, MongoDBRedisBackend): - self._show_mongodb_stats() - - elif isinstance(provider, MorkMongoDBBackend): + if isinstance(provider, (MongoDBRedisBackend, MorkMongoDBBackend)): self._show_mongodb_stats() @@ -139,151 +127,30 @@ def __init__( self._redis_container_manager = redis_container_manager self._mongodb_container_manager = mongodb_container_manager self._morkdb_container_manager = morkdb_container_manager + self._db = DbOperations(self) super().__init__() - def _get_container(self, service: str): - return { - "redis": self._redis_container_manager.get_container, - "mongodb": self._mongodb_container_manager.get_container, - "morkdb": self._morkdb_container_manager.get_container, - }[service.lower()]() - - def _stop_mork(self, container_manager, prune): - try: - container_manager.stop(remove_volume=prune) - success_msg = "The service MorkDB has been stopped." - self.stdout(success_msg, severity=StdoutSeverity.SUCCESS) - - except DockerContainerNotFoundError: - warning_msg = "The service MorkDB is already stopped." - self.stdout(warning_msg, severity=StdoutSeverity.WARNING) - self.stdout( - dict( - DbServiceResponse( - action="stop", - status="already_stopped", - message=warning_msg, - container=self._get_container("morkdb"), - ) - ), - stdout_type=StdoutType.MACHINE_READABLE, - ) - - def _stop_node( - self, manager, context: str, ip: str, username: str, prune: bool, service_name: str - ): - server_ip = self.get_execution_context().source.get("ip") or ip - - try: - manager.set_exec_context(context) - manager.stop(remove_volume=prune, force=prune) - manager.unset_exec_context() - self.stdout( - f"The {service_name} service at {server_ip} has been stopped by the server user {username}", - severity=StdoutSeverity.SUCCESS, - ) - - except DockerContainerNotFoundError: - container_name = manager.get_container().name - warning_msg = f"The {service_name} service named {container_name} at {server_ip} is already stopped." - self.stdout(warning_msg, severity=StdoutSeverity.WARNING) - self.stdout( - dict( - DbServiceResponse( - action="stop", - status="already_stopped", - message=warning_msg, - container=manager.get_container(), - extra_details={ - "node": {"context": context, "ip": ip, "username": username}, - }, - ) - ), - stdout_type=StdoutType.MACHINE_READABLE, - ) - - def _stop_service( - self, manager, nodes: list, service_name: str, prune: bool = False, cluster: bool = False - ): - self.stdout(f"Stopping {service_name} service...") - - try: - if service_name.lower() == "morkdb": - self._stop_mork(manager, prune) - - for node in nodes: - self._stop_node(manager, **node, prune=prune, service_name=service_name) - - except DockerError as e: - self.stdout( - f"\nError occurred while trying to stop {service_name}\n", - severity=StdoutSeverity.ERROR, - ) - raise e - - success_msg = f"{service_name} service stopped successfully" - self.stdout( - dict( - DbServiceResponse( - action="stop", - status="success", - message=success_msg, - container=self._get_container(service_name), - extra_details={"cluster": cluster, "nodes": nodes, "prune": prune}, - ) - ), - stdout_type=StdoutType.MACHINE_READABLE, - ) - def run(self, prune: bool = False) -> None: self._settings.validate_configuration_file() + self._db.reset() for provider in self._atomdb_backend.get_active_providers(): - if isinstance(provider, MongoDBRedisBackend): - redis_options = self._redis_container_manager._options - mongodb_options = self._mongodb_container_manager._options - - self._stop_service( - self._redis_container_manager, - redis_options["redis_nodes"], - "Redis", - prune, - redis_options["redis_cluster"], - ) - - self._stop_service( - self._mongodb_container_manager, - mongodb_options["mongodb_nodes"], - "MongoDB", - prune, - mongodb_options["mongodb_cluster"], - ) + self._db.stop_redis(self._redis_container_manager, prune=prune) + self._db.stop_mongodb(self._mongodb_container_manager, prune=prune) elif isinstance(provider, MorkMongoDBBackend): - mongodb_options = self._mongodb_container_manager._options - - self._stop_service( - self._mongodb_container_manager, - mongodb_options["mongodb_nodes"], - "MongoDB", - prune, - mongodb_options["mongodb_cluster"], - ) - - self._stop_service( - self._morkdb_container_manager, - [], - "MorkDB", - prune, - ) + self._db.stop_mongodb(self._mongodb_container_manager, prune=prune) + self._db.stop_morkdb(self._morkdb_container_manager, prune=prune) else: - self.stdout( + self.log( "InMemoryDB and RemoteDB are not supported on the 'db stop' command", severity=StdoutSeverity.WARNING, ) + self._db.finish("stop", "Database services stopped successfully.", prune=prune) + class DbStart(Command): name = "start" @@ -304,219 +171,30 @@ def __init__( self._redis_container_manager = redis_container_manager self._mongodb_container_manager = mongodb_container_manager self._morkdb_container_manager = morkdb_container_manager + self._db = DbOperations(self) super().__init__() - def _get_container(self, service: str): - return { - "redis": self._redis_container_manager.get_container, - "mongodb": self._mongodb_container_manager.get_container, - "morkdb": self._morkdb_container_manager.get_container, - }[service.lower()]() - - def _start_mork(self, container_manager, port): - try: - container_manager.start_container() - success_message = f"MorkDB service has started successfully at port {port}" - - self.stdout(success_message, severity=StdoutSeverity.SUCCESS) - self.stdout( - dict( - DbServiceResponse( - action="start", - status="success", - message=success_message, - container=container_manager.get_container(), - ) - ), - stdout_type=StdoutType.MACHINE_READABLE, - ) - - except DockerContainerDuplicateError: - warning_msg = f"MorkDB is already running at port {port}" - self.stdout(warning_msg, severity=StdoutSeverity.WARNING) - self.stdout( - dict( - DbServiceResponse( - action="start", - status="already_running", - message=warning_msg, - container=container_manager.get_container(), - ) - ), - stdout_type=StdoutType.MACHINE_READABLE, - ) - - def _start_node(self, container_manager, node: dict, service_name: str, **kwargs): - node_context = node.get("context", "") - node_ip = node.get("ip", "") - node_username = node.get("username", "") - public_ip = self.get_execution_context().source.get("ip") or node_ip - container_port = int(kwargs["port"]) - - try: - if node_context and node_context != "default": - container_manager.set_exec_context(node_context) - else: - container_manager.unset_exec_context() - - if service_name.lower() == "redis": - container_manager.start_container( - container_port, node_username, node_ip, kwargs.get("cluster", False) - ) - elif service_name.lower() == "mongodb": - container_manager.start_container( - container_port, - kwargs["username"], - kwargs["password"], - kwargs.get("cluster_node"), - kwargs.get("cluster_key"), - ) - - elif service_name == "morkdb": - container_manager.start_container() - - container_manager.unset_exec_context() - - success_msg = f"{service_name} has started successfully on port {container_port} at {public_ip}, operating under the server user {node_username}." - self.stdout(success_msg, severity=StdoutSeverity.SUCCESS) - self.stdout( - dict( - DbServiceResponse( - action="start", - status="success", - message=success_msg, - container=container_manager.get_container(), - extra_details=node, - ) - ), - stdout_type=StdoutType.MACHINE_READABLE, - ) - - except DockerContainerDuplicateError: - warning_msg = f"{service_name} is already running. It is currently listening on port {container_port} at {public_ip} under the server user {node_username}." - self.stdout(warning_msg, severity=StdoutSeverity.WARNING) - self.stdout( - dict( - DbServiceResponse( - action="start", - status="already_running", - message=warning_msg, - container=container_manager.get_container(), - extra_details=node, - ) - ), - stdout_type=StdoutType.MACHINE_READABLE, - ) - - except DockerError as e: - self.stdout( - f"\nError occurred while trying to start {service_name} at {public_ip}.\n", - severity=StdoutSeverity.ERROR, - ) - raise e - - def _start_service(self, manager, nodes: list, service_name: str, **kwargs): - try: - self.stdout(f"Starting {service_name} service...") - - if service_name.lower() == "morkdb": - self._start_mork(manager, kwargs["port"]) - - for node in nodes: - self._start_node(manager, node, service_name, **kwargs) - - if kwargs.get("cluster", False) and service_name.lower() in ("redis", "mongodb"): - try: - if service_name.lower() == "redis": - manager.start_cluster(nodes, kwargs["port"]) - else: - manager.start_cluster( - nodes, kwargs["port"], kwargs["username"], kwargs["password"] - ) - except Exception: - self.stdout( - f"\nFailed to start {service_name} cluster. Please check connectivity between nodes.\n", - severity=StdoutSeverity.ERROR, - ) - raise - - self.stdout( - dict( - DbServiceResponse( - action="start", - status="success", - message=f"{service_name.capitalize()} started successfully", - container=manager.get_container(), - extra_details={"cluster": kwargs.get("cluster", False), "nodes": nodes}, - ) - ), - stdout_type=StdoutType.MACHINE_READABLE, - ) - - except DockerError as e: - self.stdout( - f"\nError occurred while trying to start {service_name}.\n", - severity=StdoutSeverity.ERROR, - ) - raise e - def run(self): - self._settings.validate_configuration_file() + self._db.reset() for provider in self._atomdb_backend.get_active_providers(): - if isinstance(provider, MongoDBRedisBackend): - redis_options = self._redis_container_manager._options - mongodb_options = self._mongodb_container_manager._options - - self._start_service( - self._redis_container_manager, - redis_options["redis_nodes"], - service_name="Redis", - port=redis_options["redis_port"], - cluster=redis_options["redis_cluster"], - ) - - self._start_service( - self._mongodb_container_manager, - mongodb_options["mongodb_nodes"], - service_name="MongoDB", - port=mongodb_options["mongodb_port"], - username=mongodb_options["mongodb_username"], - password=mongodb_options["mongodb_password"], - cluster=mongodb_options["mongodb_cluster"], - cluster_key=mongodb_options["mongodb_cluster_secret_key"], - ) + self._db.start_redis(self._redis_container_manager) + self._db.start_mongodb(self._mongodb_container_manager) elif isinstance(provider, MorkMongoDBBackend): - mongodb_options = self._mongodb_container_manager._options - morkdb_options = self._morkdb_container_manager._options - - self._start_service( - self._mongodb_container_manager, - mongodb_options["mongodb_nodes"], - service_name="MongoDB", - port=mongodb_options["mongodb_port"], - username=mongodb_options["mongodb_username"], - password=mongodb_options["mongodb_password"], - cluster=mongodb_options["mongodb_cluster"], - cluster_key=mongodb_options["mongodb_cluster_secret_key"], - ) - - self._start_service( - self._morkdb_container_manager, - [], - service_name="MorkDB", - port=morkdb_options["morkdb_port"], - ) + self._db.start_mongodb(self._mongodb_container_manager) + self._db.start_morkdb(self._morkdb_container_manager) else: - self.stdout( + self.log( "InMemoryDB and RemoteDB are not supported on the 'db start' command", severity=StdoutSeverity.WARNING, ) + self._db.finish("start", "Database services started successfully.") + class DbRestart(Command): name = "restart" @@ -542,8 +220,8 @@ def __init__(self, db_start: DbStart, db_stop: DbStop) -> None: self._db_stop = db_stop def run(self, prune: bool = False): - self._db_stop.run(prune) - self._db_start.run() + self.run_subcommand(self._db_stop, prune) + self.run_subcommand(self._db_start) class DbCli(CommandGroup): diff --git a/das-cli/src/commands/db/db_service_response.py b/das-cli/src/commands/db/db_service_response.py deleted file mode 100644 index e8423b52..00000000 --- a/das-cli/src/commands/db/db_service_response.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Optional - -from common.docker.container_manager import Container -from common.service_response import ServiceResponse - - -class DbServiceResponse(ServiceResponse): - def __init__( - self, - action: str, - status: str, - message: str | tuple[str], - extra_details: Optional[dict] = None, - container: Optional[Container] = None, - error: Optional[dict] = None, - ): - super().__init__( - service="database", - action=action, - status=status, - message=message, - container=container, - error=error, - **(extra_details or {}), - ) diff --git a/das-cli/src/commands/db/db_services.py b/das-cli/src/commands/db/db_services.py new file mode 100644 index 00000000..a271d861 --- /dev/null +++ b/das-cli/src/commands/db/db_services.py @@ -0,0 +1,279 @@ +from common import Command, StdoutSeverity +from common.container_manager.atomdb.mongodb_container_manager import MongodbContainerManager +from common.container_manager.atomdb.morkdb_container_manager import MorkdbContainerManager +from common.container_manager.atomdb.redis_container_manager import RedisContainerManager +from common.docker.exceptions import ( + DockerContainerDuplicateError, + DockerContainerNotFoundError, + DockerError, +) +from common.exceptions import PortBindingError +from common.service_response import ServiceResponse, StdoutStatus + +CLI_SERVICE_NAME = "database" + + +class DbOperations: + def __init__(self, command: Command) -> None: + self._command = command + self.errors: list[str] = [] + + def reset(self) -> None: + self.errors = [] + + def log(self, message: str, severity: StdoutSeverity = StdoutSeverity.INFO) -> None: + self._command.log(message, severity=severity) + + def stdout(self, *args, **kwargs) -> None: + self._command.stdout(*args, **kwargs) + + def get_execution_context(self): + return self._command.get_execution_context() + + def _resolve_public_ip(self, node_ip: str) -> str: + return self.get_execution_context().source.get("ip") or node_ip + + def _set_node_context(self, manager, context: str) -> None: + if context and context != "default": + manager.set_exec_context(context) + else: + manager.unset_exec_context() + + def _record_error(self, message: str) -> None: + self.errors.append(message) + + def finish(self, action: str, success_message: str, **extra_details) -> None: + if self.errors: + error_lines = "\n".join(f"- {error}" for error in self.errors) + self.stdout( + dict( + ServiceResponse( + service=CLI_SERVICE_NAME, + action=action, + status=StdoutStatus.ERROR, + message=f"Database command failed.\n{error_lines}", + errors=self.errors, + **extra_details, + ) + ), + severity=StdoutSeverity.ERROR, + ) + return + + self.stdout( + dict( + ServiceResponse( + service=CLI_SERVICE_NAME, + action=action, + status=StdoutStatus.SUCCESS, + message=success_message, + **extra_details, + ) + ), + severity=StdoutSeverity.SUCCESS, + ) + + def start_redis(self, manager: RedisContainerManager) -> None: + self.log("Starting Redis service...", severity=StdoutSeverity.INFO) + self._start_redis_nodes(manager) + + def _start_redis_nodes(self, manager: RedisContainerManager) -> None: + options = manager._options + port = options["redis_port"] + nodes = options["redis_nodes"] + cluster = options["redis_cluster"] + redis_errors: list[str] = [] + + for node in nodes: + context = node.get("context", "") + node_username = node.get("username", "") + node_ip = node.get("ip", "") + public_ip = self._resolve_public_ip(node_ip) + + try: + self._set_node_context(manager, context) + + try: + manager.start_container(port, node_username, node_ip, cluster) + self.log( + f"Redis has started successfully on port {port} at {public_ip}, " + f"operating under the server user {node_username}.", + severity=StdoutSeverity.SUCCESS, + ) + except DockerContainerDuplicateError: + self.log( + f"Redis is already running. It is currently listening on port {port} at " + f"{public_ip} under the server user {node_username}.", + severity=StdoutSeverity.WARNING, + ) + except (DockerError, PortBindingError) as error: + msg = f"Failed to start Redis at {public_ip} under {node_username}: {error}" + redis_errors.append(msg) + self._record_error(msg) + finally: + manager.unset_exec_context() + + if cluster and not redis_errors: + try: + manager.start_cluster(nodes, port) + except Exception as error: + self._record_error( + f"Failed to start Redis cluster. Please check connectivity between nodes: {error}" + ) + + def start_mongodb(self, manager: MongodbContainerManager) -> None: + self.log("Starting MongoDB service...", severity=StdoutSeverity.INFO) + self._start_mongo_nodes(manager) + + def _start_mongo_nodes(self, manager: MongodbContainerManager) -> None: + options = manager._options + port = options["mongodb_port"] + nodes = options["mongodb_nodes"] + cluster = options["mongodb_cluster"] + username = options["mongodb_username"] + password = options["mongodb_password"] + cluster_key = options.get("mongodb_cluster_secret_key") + mongo_errors: list[str] = [] + + for node in nodes: + context = node.get("context", "") + node_username = node.get("username", "") + node_ip = node.get("ip", "") + public_ip = self._resolve_public_ip(node_ip) + cluster_node = self._normalize_cluster_node(node) if cluster else None + + try: + self._set_node_context(manager, context) + + try: + manager.start_container(port, username, password, cluster_node, cluster_key) + self.log( + f"MongoDB has started successfully on port {port} at {public_ip}, " + f"operating under the server user {node_username}.", + severity=StdoutSeverity.SUCCESS, + ) + except DockerContainerDuplicateError: + self.log( + f"MongoDB is already running. It is currently listening on port {port} at " + f"{public_ip} under the server user {node_username}.", + severity=StdoutSeverity.WARNING, + ) + except (DockerError, PortBindingError) as error: + msg = f"Failed to start MongoDB at {public_ip} under {node_username}: {error}" + mongo_errors.append(msg) + self._record_error(msg) + finally: + manager.unset_exec_context() + + if cluster and not mongo_errors: + try: + manager.start_cluster(nodes, port, username, password) + except Exception as error: + self._record_error( + f"Failed to start MongoDB cluster. Please check connectivity between nodes: {error}" + ) + + @staticmethod + def _normalize_cluster_node(node: dict) -> dict: + return { + **node, + "host": node.get("host") or node.get("ip", ""), + } + + def start_morkdb(self, manager: MorkdbContainerManager) -> None: + port = manager._options["morkdb_port"] + self.log("Starting MorkDB service...", severity=StdoutSeverity.INFO) + + try: + manager.start_container() + self.log( + f"MorkDB service has started successfully at port {port}", + severity=StdoutSeverity.SUCCESS, + ) + except DockerContainerDuplicateError: + self.log(f"MorkDB is already running at port {port}", severity=StdoutSeverity.WARNING) + except (DockerError, PortBindingError) as error: + self._record_error(f"Failed to start MorkDB at port {port}: {error}") + + def stop_redis(self, manager: RedisContainerManager, *, prune: bool = False) -> None: + self.log("Stopping Redis service...", severity=StdoutSeverity.INFO) + self._stop_redis_nodes(manager, prune=prune) + + def _stop_redis_nodes(self, manager: RedisContainerManager, *, prune: bool) -> None: + nodes = manager._options["redis_nodes"] + + for node in nodes: + context = node.get("context", "") + node_ip = node.get("ip", "") + node_username = node.get("username", "") + public_ip = self._resolve_public_ip(node_ip) + + try: + self._set_node_context(manager, context) + + try: + manager.stop(remove_volume=prune, force=prune) + self.log( + f"The Redis service at {public_ip} has been stopped " + f"by the server user {node_username}", + severity=StdoutSeverity.SUCCESS, + ) + except DockerContainerNotFoundError: + container_name = manager.get_container().name + self.log( + f"The Redis service named {container_name} at {public_ip} is already stopped.", + severity=StdoutSeverity.WARNING, + ) + except (DockerError, PortBindingError) as error: + self._record_error( + f"Failed to stop Redis at {public_ip} under {node_username}: {error}" + ) + finally: + manager.unset_exec_context() + + def stop_mongodb(self, manager: MongodbContainerManager, *, prune: bool = False) -> None: + self.log("Stopping MongoDB service...", severity=StdoutSeverity.INFO) + self._stop_mongo_nodes(manager, prune=prune) + + def _stop_mongo_nodes(self, manager: MongodbContainerManager, *, prune: bool) -> None: + nodes = manager._options["mongodb_nodes"] + + for node in nodes: + context = node.get("context", "") + node_ip = node.get("ip", "") + node_username = node.get("username", "") + public_ip = self._resolve_public_ip(node_ip) + + try: + self._set_node_context(manager, context) + + try: + manager.stop(remove_volume=prune, force=prune) + self.log( + f"The MongoDB service at {public_ip} has been stopped " + f"by the server user {node_username}", + severity=StdoutSeverity.SUCCESS, + ) + except DockerContainerNotFoundError: + container_name = manager.get_container().name + self.log( + f"The MongoDB service named {container_name} at {public_ip} is already stopped.", + severity=StdoutSeverity.WARNING, + ) + except (DockerError, PortBindingError) as error: + self._record_error( + f"Failed to stop MongoDB at {public_ip} under {node_username}: {error}" + ) + finally: + manager.unset_exec_context() + + def stop_morkdb(self, manager: MorkdbContainerManager, *, prune: bool = False) -> None: + self.log("Stopping MorkDB service...", severity=StdoutSeverity.INFO) + + try: + manager.stop(remove_volume=prune) + self.log("The service MorkDB has been stopped.", severity=StdoutSeverity.SUCCESS) + except DockerContainerNotFoundError: + self.log("The service MorkDB is already stopped.", severity=StdoutSeverity.WARNING) + except (DockerError, PortBindingError) as error: + self._record_error(f"Failed to stop MorkDB: {error}") diff --git a/das-cli/src/commands/evolution_agent/evolution_agent_cli.py b/das-cli/src/commands/evolution_agent/evolution_agent_cli.py index 1a729495..51666525 100644 --- a/das-cli/src/commands/evolution_agent/evolution_agent_cli.py +++ b/das-cli/src/commands/evolution_agent/evolution_agent_cli.py @@ -1,6 +1,6 @@ from injector import inject -from common import Command, CommandGroup, CommandOption, Settings, StdoutSeverity, StdoutType +from common import Command, CommandGroup, CommandOption, Settings, StdoutSeverity from common.container_manager.agents.generic_agent_containers import QueryAgentContainerManager from common.container_manager.busnode_container_manager import BusNodeContainerManager from common.decorators import ensure_container_running @@ -9,7 +9,9 @@ DockerContainerNotFoundError, DockerError, ) +from common.exceptions import PortBindingError from common.prompt_types import PortRangeType +from common.service_response import CONTAINER_START_FAILURE_MESSAGE, ServiceResponse, StdoutStatus from .evolution_agent_docs import ( HELP_EVOLUTION_AGENT, @@ -21,7 +23,8 @@ SHORT_HELP_START, SHORT_HELP_STOP, ) -from .evolution_agent_service_response import EvolutionAgentServiceResponse + +CLI_SERVICE_NAME = "evolution_agent" class EvolutionAgentStop(Command): @@ -35,56 +38,51 @@ class EvolutionAgentStop(Command): def __init__( self, settings: Settings, - bus_node_container_manager: BusNodeContainerManager, + evolution_agent_bus_node_manager: BusNodeContainerManager, ) -> None: super().__init__() self._settings = settings - self._evolution_agent_bus_node_manager = bus_node_container_manager + self._evolution_agent_manager = evolution_agent_bus_node_manager def _get_container(self): - return self._evolution_agent_bus_node_manager.get_container() + return self._evolution_agent_manager.get_container() def _evolution_agent(self): - try: - self.stdout("Stopping Evolution Agent service...") - self._evolution_agent_bus_node_manager.stop() + container = self._get_container() - success_message = "Evolution Agent service stopped" + self.log("Stopping Evolution Agent service...", severity=StdoutSeverity.INFO) + + try: + self._evolution_agent_manager.stop() + exec_message = "Evolution Agent service stopped" - self.stdout( - success_message, - severity=StdoutSeverity.SUCCESS, - ) self.stdout( dict( - EvolutionAgentServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="stop", - status="success", - message=success_message, - container=self._get_container(), + status=StdoutStatus.SUCCESS, + message=exec_message, + container=container, ) ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.SUCCESS, ) + except DockerContainerNotFoundError: - container_name = self._evolution_agent_bus_node_manager.get_container().name - warning_message = ( - f"The Evolution Agent service named {container_name} is already stopped." - ) - self.stdout( - warning_message, - severity=StdoutSeverity.WARNING, - ) + message = f"The Evolution Agent service named {container.name} is already stopped." + self.stdout( dict( - EvolutionAgentServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="stop", - status="already_stopped", - message=warning_message, - container=self._get_container(), + status=StdoutStatus.INFO, + message=message, + container=container, ) ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.WARNING, ) def run(self): @@ -98,7 +96,7 @@ class EvolutionAgentStart(Command): params = [ CommandOption( ["--port-range"], - help="The lower and upper bounds of the port range to be used by the node.", + help="The lower and upper bounds of the port range to be used by the command proxy.", default="45000:45999", type=PortRangeType(), ), @@ -112,64 +110,66 @@ class EvolutionAgentStart(Command): def __init__( self, settings: Settings, + evolution_agent_bus_node_manager: BusNodeContainerManager, query_agent_container_manager: QueryAgentContainerManager, - bus_node_container_manager: BusNodeContainerManager, ) -> None: super().__init__() self._settings = settings + self._evolution_agent_bus_node_manager = evolution_agent_bus_node_manager self._query_agent_container_manager = query_agent_container_manager - self._evolution_agent_bus_node_manager = bus_node_container_manager def _get_container(self): return self._evolution_agent_bus_node_manager.get_container() def _evolution_agent(self, port_range: str) -> None: - self.stdout("Starting Evolution Agent service...") - container = self._get_container() port = container.port + self.log("Starting Evolution Agent service...", severity=StdoutSeverity.INFO) + try: self._evolution_agent_bus_node_manager.start_container(port_range) - - success_message = f"Evolution Agent started on port {port}" + message = f"Evolution Agent started listening on the ports {port}" - self.stdout( - success_message, - severity=StdoutSeverity.SUCCESS, - ) self.stdout( dict( - EvolutionAgentServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="start", - status="success", - message=success_message, + status=StdoutStatus.SUCCESS, + message=message, container=container, ) ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.SUCCESS, ) + except DockerContainerDuplicateError: - warning_message = f"Evolution Agent is already running. It's listening on port {port}" + message = f"Evolution Agent is already running. It's listening on the ports {port}" self.stdout( - warning_message, + ServiceResponse( + service=CLI_SERVICE_NAME, + action="start", + status=StdoutStatus.INFO, + message=message, + container=container, + ), severity=StdoutSeverity.WARNING, ) + + except (DockerError, PortBindingError) as e: self.stdout( - dict( - EvolutionAgentServiceResponse( - action="start", - status="already_running", - message=warning_message, - container=container, - ) + ServiceResponse( + service=CLI_SERVICE_NAME, + action="start", + status=StdoutStatus.ERROR, + message=CONTAINER_START_FAILURE_MESSAGE, + error=e, + container=container, ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.ERROR, ) - except DockerError as e: - error_message = f"Error occurred while trying to start Attention Broker on port {port}" - raise DockerError(f"{error_message}\nOriginal error: {e}") @ensure_container_running( [ @@ -181,7 +181,6 @@ def _evolution_agent(self, port_range: str) -> None: ) def run(self, port_range: str): self._settings.validate_configuration_file() - self._evolution_agent(port_range) @@ -212,14 +211,14 @@ def __init__( self._evolution_agent_stop = evolution_agent_stop def run(self, port_range: str): - self._evolution_agent_stop.run() - self._evolution_agent_start.run(port_range) + self.run_subcommand(self._evolution_agent_stop) + self.run_subcommand(self._evolution_agent_start, port_range) class EvolutionAgentCli(CommandGroup): name = "evolution-agent" - aliases = ["evolution"] + aliases = ["ea"] short_help = SHORT_HELP_EVOLUTION_AGENT diff --git a/das-cli/src/commands/evolution_agent/evolution_agent_service_response.py b/das-cli/src/commands/evolution_agent/evolution_agent_service_response.py deleted file mode 100644 index 95649be2..00000000 --- a/das-cli/src/commands/evolution_agent/evolution_agent_service_response.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Optional - -from common.docker.container_manager import Container -from common.service_response import ServiceResponse - - -class EvolutionAgentServiceResponse(ServiceResponse): - def __init__( - self, - action: str, - status: str, - message: str, - container: Optional[Container] = None, - extra_details: Optional[dict] = None, - error: Optional[dict] = None, - ): - super().__init__( - service="evolution_agent", - action=action, - status=status, - message=message, - container=container, - error=error, - **(extra_details or {}), - ) diff --git a/das-cli/src/commands/example/example_cli.py b/das-cli/src/commands/example/example_cli.py index 83330d1b..80852c24 100644 --- a/das-cli/src/commands/example/example_cli.py +++ b/das-cli/src/commands/example/example_cli.py @@ -1,6 +1,6 @@ from injector import inject -from common import Command, CommandGroup, StdoutType +from common import Command, CommandGroup, StdoutSeverity from .example_docs import HELP_EX_LOCAL, HELP_EXAMPLE, SHORT_HELP_EX_LOCAL, SHORT_HELP_EXAMPLE @@ -30,7 +30,11 @@ def run(self): # Load Metta files {self._script_name} metta load """ - self.stdout(output) + self.log(output.strip(), severity=StdoutSeverity.INFO) + + if self.output_format == "plain": + return + self.stdout( { "configuration": { @@ -47,10 +51,9 @@ def run(self): }, "metta_load": { "description": "Load Metta files", - "command": f"{self._script_name} metta load ", }, - }, - stdout_type=StdoutType.MACHINE_READABLE, + } ) diff --git a/das-cli/src/commands/inference_agent/inference_agent_cli.py b/das-cli/src/commands/inference_agent/inference_agent_cli.py index de4e4ee0..56dee1df 100644 --- a/das-cli/src/commands/inference_agent/inference_agent_cli.py +++ b/das-cli/src/commands/inference_agent/inference_agent_cli.py @@ -1,9 +1,7 @@ from injector import inject -from common import Command, CommandGroup, CommandOption, Settings, StdoutSeverity, StdoutType -from common.container_manager.agents.attention_broker_container_manager import ( - AttentionBrokerManager, -) +from common import Command, CommandGroup, CommandOption, Settings, StdoutSeverity +from common.container_manager.agents.generic_agent_containers import QueryAgentContainerManager from common.container_manager.busnode_container_manager import BusNodeContainerManager from common.decorators import ensure_container_running from common.docker.exceptions import ( @@ -11,9 +9,10 @@ DockerContainerNotFoundError, DockerError, ) +from common.exceptions import PortBindingError from common.prompt_types import PortRangeType +from common.service_response import CONTAINER_START_FAILURE_MESSAGE, ServiceResponse, StdoutStatus -from .inference_agent_container_service_response import InferenceAgentContainerServiceResponse from .inference_agent_docs import ( HELP_INFERENCE, HELP_RESTART, @@ -25,6 +24,8 @@ SHORT_HELP_STOP, ) +CLI_SERVICE_NAME = "inference_agent" + class InferenceAgentStop(Command): name = "stop" @@ -37,58 +38,51 @@ class InferenceAgentStop(Command): def __init__( self, settings: Settings, - bus_node_manager: BusNodeContainerManager, + inference_agent_bus_node_manager: BusNodeContainerManager, ) -> None: super().__init__() self._settings = settings - self._inference_agent_bus_node_manager = bus_node_manager + self._inference_agent_manager = inference_agent_bus_node_manager def _get_container(self): - return self._inference_agent_bus_node_manager.get_container() + return self._inference_agent_manager.get_container() def _inference_agent(self): container = self._get_container() - try: - self.stdout("Stopping Inference Agent service...") - self._inference_agent_bus_node_manager.stop() + self.log("Stopping Inference Agent service...", severity=StdoutSeverity.INFO) - success_message = "Inference Agent service stopped" + try: + self._inference_agent_manager.stop() + exec_message = "Inference Agent service stopped" - self.stdout( - success_message, - severity=StdoutSeverity.SUCCESS, - ) self.stdout( dict( - InferenceAgentContainerServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="stop", - status="success", - message=success_message, + status=StdoutStatus.SUCCESS, + message=exec_message, container=container, ) ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.SUCCESS, ) except DockerContainerNotFoundError: - warning_message = ( - f"The Inference Agent service named {container.name} is already stopped." - ) - self.stdout( - warning_message, - severity=StdoutSeverity.WARNING, - ) + message = f"The Inference Agent service named {container.name} is already stopped." + self.stdout( dict( - InferenceAgentContainerServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="stop", - status="already_stopped", - message=warning_message, + status=StdoutStatus.INFO, + message=message, container=container, ) ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.WARNING, ) def run(self): @@ -116,80 +110,77 @@ class InferenceAgentStart(Command): def __init__( self, settings: Settings, - bus_node_container_manager: BusNodeContainerManager, - attention_broker_container_manager: AttentionBrokerManager, + inference_agent_bus_node_manager: BusNodeContainerManager, + query_agent_container_manager: QueryAgentContainerManager, ) -> None: super().__init__() self._settings = settings - self._inference_agent_bus_node_manager = bus_node_container_manager - self._attention_broker_container_manager = attention_broker_container_manager + self._inference_agent_bus_node_manager = inference_agent_bus_node_manager + self._query_agent_container_manager = query_agent_container_manager def _get_container(self): return self._inference_agent_bus_node_manager.get_container() def _inference_agent(self, port_range: str) -> None: container = self._get_container() + port = container.port - self.stdout("Starting Inference Agent service...") - - inf_a_port = container.port + self.log("Starting Inference Agent service...", severity=StdoutSeverity.INFO) try: self._inference_agent_bus_node_manager.start_container(port_range) + message = f"Inference Agent started listening on the ports {port}" - success_message = f"Inference Agent started listening on the ports {inf_a_port}" - - self.stdout( - success_message, - severity=StdoutSeverity.SUCCESS, - ) self.stdout( dict( - InferenceAgentContainerServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="start", - status="success", - message=success_message, + status=StdoutStatus.SUCCESS, + message=message, container=container, ) ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.SUCCESS, ) + except DockerContainerDuplicateError: - warning_message = ( - f"Inference Agent is already running. It's listening on the ports {container.port}" - ) + message = f"Inference Agent is already running. It's listening on the ports {port}" + self.stdout( - warning_message, + ServiceResponse( + service=CLI_SERVICE_NAME, + action="start", + status=StdoutStatus.INFO, + message=message, + container=container, + ), severity=StdoutSeverity.WARNING, ) + + except (DockerError, PortBindingError) as e: self.stdout( - dict( - InferenceAgentContainerServiceResponse( - action="start", - status="already_running", - message=warning_message, - container=container, - ) + ServiceResponse( + service=CLI_SERVICE_NAME, + action="start", + status=StdoutStatus.ERROR, + message=CONTAINER_START_FAILURE_MESSAGE, + error=e, + container=container, ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.ERROR, ) - except DockerError as e: - error_message = ( - f"Error occurred while trying to start Attention Broker on port {inf_a_port}" - ) - raise DockerError(f"{error_message}\nOriginal error: {e}") @ensure_container_running( [ - "_attention_broker_container_manager", + "_query_agent_container_manager", ], exception_text="\nPlease start the required services before running 'inference-agent start'.\n" - "Run 'attention-broker start' to start the Attention Broker.", + "Run 'query-agent start' to start the Query Agent.", verbose=False, ) def run(self, port_range: str): self._settings.validate_configuration_file() - self._inference_agent(port_range) @@ -220,14 +211,14 @@ def __init__( self._inference_agent_stop = inference_agent_stop def run(self, port_range: str): - self._inference_agent_stop.run() - self._inference_agent_start.run(port_range) + self.run_subcommand(self._inference_agent_stop) + self.run_subcommand(self._inference_agent_start, port_range) class InferenceAgentCli(CommandGroup): name = "inference-agent" - aliases = ["inference"] + aliases = ["ia"] short_help = SHORT_HELP_INFERENCE diff --git a/das-cli/src/commands/inference_agent/inference_agent_container_service_response.py b/das-cli/src/commands/inference_agent/inference_agent_container_service_response.py deleted file mode 100644 index 69774fa6..00000000 --- a/das-cli/src/commands/inference_agent/inference_agent_container_service_response.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Optional - -from common.docker.container_manager import Container -from common.service_response import ServiceResponse - - -class InferenceAgentContainerServiceResponse(ServiceResponse): - def __init__( - self, - action: str, - status: str, - message: str, - container: Optional[Container] = None, - extra_details: Optional[dict] = None, - error: Optional[dict] = None, - ): - super().__init__( - service="inference_agent", - action=action, - status=status, - message=message, - container=container, - error=error, - **(extra_details or {}), - ) diff --git a/das-cli/src/commands/inference_agent/inference_agent_module.py b/das-cli/src/commands/inference_agent/inference_agent_module.py index 3c654ba1..da938b5a 100644 --- a/das-cli/src/commands/inference_agent/inference_agent_module.py +++ b/das-cli/src/commands/inference_agent/inference_agent_module.py @@ -2,12 +2,18 @@ from common import Module from common.config.store import JsonConfigStore +from common.container_manager.agents.generic_agent_containers import ( + ContainerTypes, + QueryAgentContainerManager, +) from common.container_manager.busnode_container_manager import BusNodeContainerManager -from common.factory.attention_broker_manager_factory import AttentionBrokerManagerFactory -from common.factory.busnode_manager_factory import BusNodeContainerManagerFactory +from common.factory.busnode_manager_factory import ( + BusNodeContainerManagerFactory, +) +from common.factory.container_manager_factory import ContainerManagerFactory from settings.config import SECRETS_PATH -from .inference_agent_cli import AttentionBrokerManager, InferenceAgentCli, Settings +from .inference_agent_cli import InferenceAgentCli, Settings class InferenceAgentModule(Module): @@ -18,6 +24,7 @@ def __init__(self) -> None: self._settings = Settings(store=JsonConfigStore(os.path.expanduser(SECRETS_PATH))) self._bus_node_factory = BusNodeContainerManagerFactory() + self._container_manager_factory = ContainerManagerFactory() self._dependency_list = [ ( @@ -27,8 +34,8 @@ def __init__(self) -> None: ), ), ( - AttentionBrokerManager, - AttentionBrokerManagerFactory().build(), + QueryAgentContainerManager, + self._container_manager_factory.build(ContainerTypes.QUERY_ENGINE), ), ( Settings, diff --git a/das-cli/src/commands/jupyter_notebook/jupyter_notebook_agent_container_service_response.py b/das-cli/src/commands/jupyter_notebook/jupyter_notebook_agent_container_service_response.py deleted file mode 100644 index b3960ba4..00000000 --- a/das-cli/src/commands/jupyter_notebook/jupyter_notebook_agent_container_service_response.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Optional - -from common.docker.container_manager import Container -from common.service_response import ServiceResponse - - -class JupyterNotebookContainerServiceResponse(ServiceResponse): - def __init__( - self, - action: str, - status: str, - message: str, - container: Optional[Container] = None, - extra_details: Optional[dict] = None, - error: Optional[dict] = None, - ): - super().__init__( - service="jupyter_notebook", - action=action, - status=status, - message=message, - container=container, - error=error, - **(extra_details or {}), - ) diff --git a/das-cli/src/commands/jupyter_notebook/jupyter_notebook_cli.py b/das-cli/src/commands/jupyter_notebook/jupyter_notebook_cli.py index d9a7098a..3e46ad76 100644 --- a/das-cli/src/commands/jupyter_notebook/jupyter_notebook_cli.py +++ b/das-cli/src/commands/jupyter_notebook/jupyter_notebook_cli.py @@ -1,6 +1,6 @@ from injector import inject -from common import Command, CommandGroup, CommandOption, Settings, StdoutSeverity, StdoutType +from common import Command, CommandGroup, CommandOption, Settings, StdoutSeverity from common.container_manager.agents.jupyter_notebook_container_manager import ( JupyterNotebookContainerManager, ) @@ -9,7 +9,9 @@ DockerContainerNotFoundError, DockerError, ) +from common.exceptions import PortBindingError from common.prompt_types import AbsolutePath +from common.service_response import CONTAINER_START_FAILURE_MESSAGE, ServiceResponse, StdoutStatus from .jupyter_docs import ( HELP_JUPYTER, @@ -21,9 +23,8 @@ SHORT_HELP_START, SHORT_HELP_STOP, ) -from .jupyter_notebook_agent_container_service_response import ( - JupyterNotebookContainerServiceResponse, -) + +CLI_SERVICE_NAME = "jupyter_notebook" class JupyterNotebookStart(Command): @@ -65,56 +66,57 @@ def _get_container(self): def run(self, working_dir: str | None = None): self._settings.validate_configuration_file() - self.stdout("Starting Jupyter Notebook...") - container = self._get_container() + self.log("Starting Jupyter Notebook...", severity=StdoutSeverity.INFO) + try: self._jupyter_notebook_container_manager.start_container(working_dir) - success_message = f"Jupyter Notebook started on port {container.port}" - self.stdout( - success_message, - severity=StdoutSeverity.SUCCESS, - ) + message = f"Jupyter Notebook started on port {container.port}" + self.stdout( dict( - JupyterNotebookContainerServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="start", - status="success", - message=success_message, + status=StdoutStatus.SUCCESS, + message=message, container=container, - extra_details={ - "working_dir": working_dir, - }, + working_dir=working_dir, ), ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.SUCCESS, ) + except DockerContainerDuplicateError: - warning_message = ( + message = ( f"Jupyter Notebook is already running. It's listening on port {container.port}" ) + self.stdout( - warning_message, + ServiceResponse( + service=CLI_SERVICE_NAME, + action="start", + status=StdoutStatus.INFO, + message=message, + container=container, + working_dir=working_dir, + ), severity=StdoutSeverity.WARNING, ) + + except (DockerError, PortBindingError) as e: self.stdout( - dict( - JupyterNotebookContainerServiceResponse( - action="start", - status="already_running", - message=warning_message, - container=container, - extra_details={ - "working_dir": working_dir, - }, - ), + ServiceResponse( + service=CLI_SERVICE_NAME, + action="start", + status=StdoutStatus.ERROR, + message=CONTAINER_START_FAILURE_MESSAGE, + error=e, + container=container, + working_dir=working_dir, ), - stdout_type=StdoutType.MACHINE_READABLE, - ) - except DockerError: - raise DockerError( - f"\nError occurred while trying to start Jupyter Notebook on port {container.port}\n" + severity=StdoutSeverity.ERROR, ) @@ -143,47 +145,39 @@ def run(self): container = self._get_container() - self.stdout("Stopping jupyter notebook...") + self.log("Stopping jupyter notebook...", severity=StdoutSeverity.INFO) try: self._jupyter_notebook_container_manager.stop() - - success_message = "Jupyter Notebook service stopped" - self.stdout( - success_message, - severity=StdoutSeverity.SUCCESS, - ) + exec_message = "Jupyter Notebook service stopped" self.stdout( dict( - JupyterNotebookContainerServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="stop", - status="success", - message=success_message, + status=StdoutStatus.SUCCESS, + message=exec_message, container=container, ), ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.SUCCESS, ) + except DockerContainerNotFoundError: - warning_message = ( - f"The Jupyter Notebook service named {container.name} is already stopped." - ) + message = f"The Jupyter Notebook service named {container.name} is already stopped." - self.stdout( - warning_message, - severity=StdoutSeverity.WARNING, - ) self.stdout( dict( - JupyterNotebookContainerServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="stop", - status="already_stopped", - message=warning_message, + status=StdoutStatus.INFO, + message=message, container=container, ), ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.WARNING, ) @@ -221,8 +215,8 @@ def __init__( self._jupyter_notebook_stop = jupyter_notebook_stop def run(self, working_dir: str | None = None): - self._jupyter_notebook_stop.run() - self._jupyter_notebook_start.run(working_dir) + self.run_subcommand(self._jupyter_notebook_stop) + self.run_subcommand(self._jupyter_notebook_start, working_dir) class JupyterNotebookCli(CommandGroup): diff --git a/das-cli/src/commands/link_creation_agent/link_creation_agent_cli.py b/das-cli/src/commands/link_creation_agent/link_creation_agent_cli.py index 407ecdfc..ad487539 100644 --- a/das-cli/src/commands/link_creation_agent/link_creation_agent_cli.py +++ b/das-cli/src/commands/link_creation_agent/link_creation_agent_cli.py @@ -1,6 +1,6 @@ from injector import inject -from common import Command, CommandGroup, CommandOption, Settings, StdoutSeverity, StdoutType +from common import Command, CommandGroup, CommandOption, Settings, StdoutSeverity from common.container_manager.agents.generic_agent_containers import QueryAgentContainerManager from common.container_manager.busnode_container_manager import BusNodeContainerManager from common.decorators import ensure_container_running @@ -9,7 +9,9 @@ DockerContainerNotFoundError, DockerError, ) +from common.exceptions import PortBindingError from common.prompt_types import PortRangeType +from common.service_response import CONTAINER_START_FAILURE_MESSAGE, ServiceResponse, StdoutStatus from .lca_docs import ( HELP_LCA, @@ -21,9 +23,8 @@ SHORT_HELP_START, SHORT_HELP_STOP, ) -from .link_creation_agent_container_service_response import ( - LinkCreationAgentContainerServiceResponse, -) + +CLI_SERVICE_NAME = "link_creation_agent" class LinkCreationAgentStop(Command): @@ -49,51 +50,43 @@ def _get_container(self): def _link_creation_agent(self): container = self._get_container() + self.log("Stopping Link Creation Agent service...", severity=StdoutSeverity.INFO) + try: - self.stdout("Stopping Link Creation Agent service...") self._link_creation_agent_manager.stop() - - success_message = "Link Creation Agent service stopped" + exec_message = "Link Creation Agent service stopped" - self.stdout( - success_message, - severity=StdoutSeverity.SUCCESS, - ) self.stdout( dict( - LinkCreationAgentContainerServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="stop", - status="success", - message=success_message, + status=StdoutStatus.SUCCESS, + message=exec_message, container=container, ) ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.SUCCESS, ) except DockerContainerNotFoundError: - warning_message = ( - f"The Link Creation Agent service named {container.name} is already stopped." - ) - self.stdout( - warning_message, - severity=StdoutSeverity.WARNING, - ) + message = f"The Link Creation Agent service named {container.name} is already stopped." + self.stdout( dict( - LinkCreationAgentContainerServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="stop", - status="already_stopped", - message=warning_message, + status=StdoutStatus.INFO, + message=message, container=container, ) ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.WARNING, ) def run(self): self._settings.validate_configuration_file() - self._link_creation_agent() @@ -129,53 +122,54 @@ def _get_container(self): return self._link_creation_bus_node_manager.get_container() def _link_creation_agent(self, port_range: str) -> None: - self.stdout("Starting Link Creation Agent service...") + container = self._get_container() + port = container.port - try: - container = self._get_container() - port = container.port + self.log("Starting Link Creation Agent service...", severity=StdoutSeverity.INFO) + try: self._link_creation_bus_node_manager.start_container(port_range) + message = f"Link Creation Agent started listening on the ports {port}" - success_message = f"Link Creation Agent started listening on the ports {port}" - self.stdout( - success_message, - severity=StdoutSeverity.SUCCESS, - ) self.stdout( dict( - LinkCreationAgentContainerServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="start", - status="success", - message=success_message, + status=StdoutStatus.SUCCESS, + message=message, container=container, - ), + ) ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.SUCCESS, ) + except DockerContainerDuplicateError: - warning_message = ( - f"Link Creation Agent is already running. It's listening on the ports {port}" - ) + message = f"Link Creation Agent is already running. It's listening on the ports {port}" self.stdout( - warning_message, + ServiceResponse( + service=CLI_SERVICE_NAME, + action="start", + status=StdoutStatus.INFO, + message=message, + container=container, + ), severity=StdoutSeverity.WARNING, ) + + except (DockerError, PortBindingError) as e: self.stdout( - dict( - LinkCreationAgentContainerServiceResponse( - action="start", - status="already_running", - message=warning_message, - container=container, - ) + ServiceResponse( + service=CLI_SERVICE_NAME, + action="start", + status=StdoutStatus.ERROR, + message=CONTAINER_START_FAILURE_MESSAGE, + error=e, + container=container, ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.ERROR, ) - except DockerError as e: - error_message = f"Error occurred while trying to start Attention Broker on port {port}" - raise DockerError(f"{error_message}\nOriginal error: {e}") @ensure_container_running( [ @@ -217,8 +211,8 @@ def __init__( self._link_creation_agent_stop = link_creation_agent_stop def run(self, port_range: str): - self._link_creation_agent_stop.run() - self._link_creation_agent_start.run(port_range) + self.run_subcommand(self._link_creation_agent_stop) + self.run_subcommand(self._link_creation_agent_start, port_range) class LinkCreationAgentCli(CommandGroup): diff --git a/das-cli/src/commands/link_creation_agent/link_creation_agent_container_service_response.py b/das-cli/src/commands/link_creation_agent/link_creation_agent_container_service_response.py deleted file mode 100644 index 95334e01..00000000 --- a/das-cli/src/commands/link_creation_agent/link_creation_agent_container_service_response.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Optional - -from common.docker.container_manager import Container -from common.service_response import ServiceResponse - - -class LinkCreationAgentContainerServiceResponse(ServiceResponse): - def __init__( - self, - action: str, - status: str, - message: str, - container: Optional[Container] = None, - extra_details: Optional[dict] = None, - error: Optional[dict] = None, - ): - super().__init__( - service="link_creation_agent", - action=action, - status=status, - message=message, - container=container, - error=error, - **(extra_details or {}), - ) diff --git a/das-cli/src/commands/metta/metta_cli.py b/das-cli/src/commands/metta/metta_cli.py index 6e99f833..de126f88 100644 --- a/das-cli/src/commands/metta/metta_cli.py +++ b/das-cli/src/commands/metta/metta_cli.py @@ -14,6 +14,7 @@ from common.docker.exceptions import DockerError from common.factory.atomdb.atomdb_backend import AtomdbBackend from common.prompt_types import AbsolutePath +from common.service_response import ServiceResponse, StdoutStatus from .metta_docs import ( HELP_CHECK, @@ -24,6 +25,8 @@ SHORT_HELP_METTA, ) +CLI_SERVICE_NAME = "metta" + class MettaLoad(Command): name = "load" @@ -69,21 +72,52 @@ def __init__( ) def run(self, path: str): self._settings.validate_configuration_file() - self._check_path_exists(path) - self._load_metta(path) - - def _load_metta(self, path: str): if self._check_if_file_or_directory(path): - self._load_metta_from_directory(path) + loaded_files, errors = self._load_metta_from_directory(path) else: - self._load_metta_from_file(path) + loaded_files = [] + errors = [] + try: + loaded_files.append(self._load_metta_from_file(path)) + except Exception as error: + errors.append(str(error)) + + self._finish_load(path, loaded_files, errors) + def _finish_load(self, path: str, loaded_files: list[str], errors: list[str]) -> None: + if errors: + error_lines = "\n".join(f"- {error}" for error in errors) self.stdout( - "Done loading.", - severity=StdoutSeverity.SUCCESS, + dict( + ServiceResponse( + service=CLI_SERVICE_NAME, + action="load", + status=StdoutStatus.ERROR, + message=f"MeTTa load failed for '{path}'.\n{error_lines}", + path=path, + loaded_files=loaded_files, + errors=errors, + ) + ), + severity=StdoutSeverity.ERROR, ) + return + + self.stdout( + dict( + ServiceResponse( + service=CLI_SERVICE_NAME, + action="load", + status=StdoutStatus.SUCCESS, + message=f"MeTTa loaded successfully from '{path}'.", + path=path, + loaded_files=loaded_files, + ) + ), + severity=StdoutSeverity.SUCCESS, + ) def _check_path_exists(self, file_path: str): if not os.path.exists(file_path): @@ -112,47 +146,41 @@ def _check_if_directory_has_permissions(self, dir_path: str): def _validate_metta_syntax(self, file_path: str): try: self._metta_syntax_container_manager.start_container(file_path) - except DockerError: + except DockerError as error: raise DockerError( f"Syntax validation failed for '{file_path}'. " "The file contains invalid MeTTa syntax." - ) + ) from error - def _load_metta_from_file(self, file_path: str): - self.stdout(f"Loading metta file {file_path}...") + def _load_metta_from_file(self, file_path: str) -> str: + self.log(f"Loading metta file {file_path}...", severity=StdoutSeverity.INFO) self._check_file_and_permissions(file_path) - self.stdout("Validating syntax...") - + self.log("Validating syntax...", severity=StdoutSeverity.INFO) self._validate_metta_syntax(file_path) - - self.stdout( - "Syntax validation passed.", - severity=StdoutSeverity.SUCCESS, - ) + self.log("Syntax validation passed.", severity=StdoutSeverity.SUCCESS) self._database_loader_container_manager.start_container(file_path) + self.log(f"Done loading {file_path}.", severity=StdoutSeverity.SUCCESS) - def _load_metta_from_directory(self, directory_path: str): + return file_path + + def _load_metta_from_directory(self, directory_path: str) -> tuple[list[str], list[str]]: self._check_if_directory_has_permissions(directory_path) - files = glob.glob(f"{directory_path}/*") + loaded_files: list[str] = [] + errors: list[str] = [] - for file_path in files: + for file_path in glob.glob(f"{directory_path}/*"): try: - self._load_metta_from_file(file_path) - - self.stdout( - "Done loading.", - severity=StdoutSeverity.SUCCESS, - ) + loaded_files.append(self._load_metta_from_file(file_path)) + except Exception as error: + message = f"Failed loading '{file_path}': {error}" + errors.append(message) + self.log(message, severity=StdoutSeverity.ERROR) - except Exception as e: - self.stdout( - f"Failed loading file.\nReason: {e}", - severity=StdoutSeverity.ERROR, - ) + return loaded_files, errors class MettaCheck(Command): @@ -179,41 +207,85 @@ def __init__( self._metta_syntax_container_manager = metta_syntax_container_manager self._settings = settings - def check_syntax(self, file_path): - self._metta_syntax_container_manager.start_container(file_path) + def run(self, path: str): + self._settings.validate_configuration_file() - self.stdout( - "Checking syntax... OK", - severity=StdoutSeverity.SUCCESS, - ) + if os.path.isdir(path): + checked_files, errors = self._validate_directory(path) + else: + checked_files, errors = self._validate_file(path) - def validate_file(self, file_path): - self.stdout(f"Checking file {file_path}:") - try: - self.check_syntax(file_path) - except IsADirectoryError: - raise IsADirectoryError(f"The specified path '{file_path}' is a directory.") - except FileNotFoundError: - raise FileNotFoundError(f"The specified file path '{file_path}' does not exist.") - except DockerError: + self._finish_check(path, checked_files, errors) + + def _finish_check(self, path: str, checked_files: list[str], errors: list[str]) -> None: + if errors: + error_lines = "\n".join(f"- {error}" for error in errors) self.stdout( - "Checking syntax... FAILED", + dict( + ServiceResponse( + service=CLI_SERVICE_NAME, + action="check", + status=StdoutStatus.ERROR, + message=f"MeTTa syntax check failed for '{path}'.\n{error_lines}", + path=path, + checked_files=checked_files, + errors=errors, + ) + ), severity=StdoutSeverity.ERROR, ) + return - def validate_directory(self, directory_path): - files = glob.glob(f"{directory_path}/*") + self.stdout( + dict( + ServiceResponse( + service=CLI_SERVICE_NAME, + action="check", + status=StdoutStatus.SUCCESS, + message=f"MeTTa syntax check passed for '{path}'.", + path=path, + checked_files=checked_files, + ) + ), + severity=StdoutSeverity.SUCCESS, + ) - for file_path in files: - self.validate_file(file_path) + def _check_syntax(self, file_path: str) -> None: + self._metta_syntax_container_manager.start_container(file_path) + self.log(f"Checking syntax for {file_path}... OK", severity=StdoutSeverity.SUCCESS) - def run(self, path: str): - self._settings.validate_configuration_file() + def _validate_file(self, file_path: str) -> tuple[list[str], list[str]]: + self.log(f"Checking file {file_path}:", severity=StdoutSeverity.INFO) - if os.path.isdir(path): - self.validate_directory(path) - else: - self.validate_file(path) + try: + self._check_syntax(file_path) + return [file_path], [] + except IsADirectoryError as error: + raise IsADirectoryError(f"The specified path '{file_path}' is a directory.") from error + except FileNotFoundError as error: + raise FileNotFoundError( + f"The specified file path '{file_path}' does not exist." + ) from error + except DockerError as error: + message = f"Checking syntax for {file_path}... FAILED: {error}" + self.log(message, severity=StdoutSeverity.ERROR) + return [], [message] + + def _validate_directory(self, directory_path: str) -> tuple[list[str], list[str]]: + checked_files: list[str] = [] + errors: list[str] = [] + + for file_path in glob.glob(f"{directory_path}/*"): + try: + file_checked, file_errors = self._validate_file(file_path) + except (IsADirectoryError, FileNotFoundError) as error: + errors.append(str(error)) + continue + + checked_files.extend(file_checked) + errors.extend(file_errors) + + return checked_files, errors class MettaCli(CommandGroup): diff --git a/das-cli/src/commands/query_agent/query_agent_cli.py b/das-cli/src/commands/query_agent/query_agent_cli.py index 1410a93b..c359621b 100644 --- a/das-cli/src/commands/query_agent/query_agent_cli.py +++ b/das-cli/src/commands/query_agent/query_agent_cli.py @@ -1,6 +1,6 @@ from injector import inject -from common import Command, CommandGroup, CommandOption, Settings, StdoutSeverity, StdoutType +from common import Command, CommandGroup, CommandOption, Settings, StdoutSeverity from common.container_manager.agents.attention_broker_container_manager import ( AttentionBrokerManager, ) @@ -11,10 +11,11 @@ DockerContainerNotFoundError, DockerError, ) +from common.exceptions import PortBindingError from common.factory.atomdb.atomdb_backend import AtomdbBackend from common.prompt_types import PortRangeType +from common.service_response import CONTAINER_START_FAILURE_MESSAGE, ServiceResponse, StdoutStatus -from .query_agent_container_service_response import QueryAgentContainerServiceResponse from .query_agent_docs import ( HELP_QA, HELP_RESTART, @@ -26,6 +27,8 @@ SHORT_HELP_STOP, ) +CLI_SERVICE_NAME = "query_agent" + class QueryAgentStop(Command): name = "stop" @@ -48,49 +51,45 @@ def _get_container(self): return self._query_agent_bus_manager.get_container() def _query_agent(self): + container = self._get_container() + + self.log("Stopping Query Agent service...", severity=StdoutSeverity.INFO) + try: - self.stdout("Stopping Query Agent service...") self._query_agent_bus_manager.stop() + exec_message = "Query Agent service stopped" - success_message = "Query Agent service stopped" - self.stdout( - success_message, - severity=StdoutSeverity.SUCCESS, - ) self.stdout( dict( - QueryAgentContainerServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="stop", - status="success", - message=success_message, - container=self._get_container(), + status=StdoutStatus.SUCCESS, + message=exec_message, + container=container, ) ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.SUCCESS, ) + except DockerContainerNotFoundError: - container_name = self._get_container().name - warning_message = f"The Query Agent service named {container_name} is already stopped." - self.stdout( - warning_message, - severity=StdoutSeverity.WARNING, - ) + message = f"The Query Agent service named {container.name} is already stopped." + self.stdout( dict( - QueryAgentContainerServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="stop", - status="already_stopped", - message=warning_message, - container=self._get_container(), + status=StdoutStatus.INFO, + message=message, + container=container, ) ), - stdout_type=StdoutType.MACHINE_READABLE, severity=StdoutSeverity.WARNING, ) def run(self): self._settings.validate_configuration_file() - self._query_agent() @@ -128,58 +127,55 @@ def _get_container(self): return self._bus_node_container_manager.get_container() def _query_engine_node(self, port_range: str, **kwargs) -> None: - self.stdout("Starting Query Agent service...") + container = self._get_container() + port = container.port - try: - container_port = self._get_container().port + self.log("Starting Query Agent service...", severity=StdoutSeverity.INFO) + try: self._bus_node_container_manager.start_container(port_range, **kwargs) - - success_message = f"Query Agent started on port {container_port}" - self.stdout( - success_message, - severity=StdoutSeverity.SUCCESS, - ) + message = f"Query Agent started on port {port}" self.stdout( dict( - QueryAgentContainerServiceResponse( + ServiceResponse( + service=CLI_SERVICE_NAME, action="start", - status="success", - message=success_message, - container=self._get_container(), + status=StdoutStatus.SUCCESS, + message=message, + container=container, ) ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.SUCCESS, ) + except DockerContainerDuplicateError: - warning_message = ( - f"Query Agent is already running. It's listening on port {container_port}" - ) + message = f"Query Agent is already running. It's listening on port {port}" self.stdout( - warning_message, + ServiceResponse( + service=CLI_SERVICE_NAME, + action="start", + status=StdoutStatus.INFO, + message=message, + container=container, + ), severity=StdoutSeverity.WARNING, ) + except (DockerError, PortBindingError) as e: self.stdout( - dict( - QueryAgentContainerServiceResponse( - action="start", - status="already_running", - message=warning_message, - container=self._get_container(), - ) + ServiceResponse( + service=CLI_SERVICE_NAME, + action="start", + status=StdoutStatus.ERROR, + message=CONTAINER_START_FAILURE_MESSAGE, + error=e, + container=container, ), - stdout_type=StdoutType.MACHINE_READABLE, + severity=StdoutSeverity.ERROR, ) - except DockerError as e: - error_message = ( - f"Error occurred while trying to start Query Agent on port {container_port}" - ) - raise DockerError(f"{error_message}\nOriginal error: {e}") - @ensure_container_running( [ "_atomdb_backend", @@ -191,7 +187,6 @@ def _query_engine_node(self, port_range: str, **kwargs) -> None: ) def run(self, port_range: str, **kwargs) -> None: self._settings.validate_configuration_file() - self._query_engine_node(port_range, **kwargs) @@ -222,8 +217,8 @@ def __init__( self._query_agent_stop = query_agent_stop def run(self, port_range: str): - self._query_agent_stop.run() - self._query_agent_start.run(port_range=port_range) + self.run_subcommand(self._query_agent_stop) + self.run_subcommand(self._query_agent_start, port_range=port_range) class QueryAgentCli(CommandGroup): diff --git a/das-cli/src/commands/query_agent/query_agent_container_service_response.py b/das-cli/src/commands/query_agent/query_agent_container_service_response.py deleted file mode 100644 index dcf1c773..00000000 --- a/das-cli/src/commands/query_agent/query_agent_container_service_response.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Optional - -from common.docker.container_manager import Container -from common.service_response import ServiceResponse - - -class QueryAgentContainerServiceResponse(ServiceResponse): - def __init__( - self, - action: str, - status: str, - message: str, - container: Optional[Container] = None, - extra_details: Optional[dict] = None, - error: Optional[dict] = None, - ): - super().__init__( - service="query_agent", - action=action, - status=status, - message=message, - container=container, - error=error, - **(extra_details or {}), - ) diff --git a/das-cli/src/commands/system/system_cli.py b/das-cli/src/commands/system/system_cli.py index fe27f336..59e60f62 100644 --- a/das-cli/src/commands/system/system_cli.py +++ b/das-cli/src/commands/system/system_cli.py @@ -4,7 +4,7 @@ from injector import inject -from common import Command, CommandGroup, CommandOption, Settings, StdoutType +from common import Command, CommandGroup, CommandOption, Settings, StdoutSeverity from common.container_manager.system_containers_manager import ( SystemContainersManager, ) @@ -79,12 +79,11 @@ def run( # Solo snapshot system_info = self._collect_snapshot() - self.stdout( - system_info, - stdout_type=StdoutType.MACHINE_READABLE, - ) - self._format_info_for_display(system_info) + if self.output_format != "plain": + self.stdout(system_info) + else: + self._format_info_for_display(system_info) def _collect_snapshot(self) -> dict: @@ -101,6 +100,9 @@ def _collect_snapshot(self) -> dict: "serviceInfo": service_output, } + def _log_line(self, message: str) -> None: + self.log(message, severity=StdoutSeverity.INFO) + def _format_info_for_display( self, system_info: dict, @@ -113,7 +115,7 @@ def _format_info_for_display( memory_info = machines.get("MemoryInfo", {}) disks_info = machines.get("DisksInfo", []) - self.stdout("MACHINE INFO:\n") + self._log_line("MACHINE INFO:\n") machine_rows = [ { @@ -132,10 +134,10 @@ def _format_info_for_display( "MEM USED (GB)", "MEM TOTAL (GB)", ], - stdout=self.stdout, + stdout=self._log_line, ) - self.stdout("\nDISKS:\n") + self._log_line("\nDISKS:\n") disk_rows = [] @@ -158,10 +160,10 @@ def _format_info_for_display( "USED (GB)", "TOTAL (GB)", ], - stdout=self.stdout, + stdout=self._log_line, ) - self.stdout("\nSERVICES:\n") + self._log_line("\nSERVICES:\n") container_rows = [] @@ -192,7 +194,7 @@ def _format_info_for_display( "CONTAINER STATUS", "SERVICE HEALTH", ], - stdout=self.stdout, + stdout=self._log_line, ) def _run_stream(self, cooldown) -> None: @@ -251,10 +253,11 @@ def docker_loop(): "serviceInfo": dict(latest_services), } - os.system("clear") - - self.stdout(system_info, stdout_type=StdoutType.MACHINE_READABLE, stream_mode=True) - self._format_info_for_display(system_info) + if self.output_format == "plain": + os.system("clear") + self._format_info_for_display(system_info) + else: + self.stdout(system_info) time.sleep(cooldown) except KeyboardInterrupt: diff --git a/das-cli/src/common/__init__.py b/das-cli/src/common/__init__.py index 0c94adcd..a314f735 100644 --- a/das-cli/src/common/__init__.py +++ b/das-cli/src/common/__init__.py @@ -7,7 +7,6 @@ CommandGroup, CommandOption, StdoutSeverity, - StdoutType, ) from .docker import Container, ContainerManager, ImageManager, RemoteContextManager from .docker.container_manager import ContainerImageMetadata, ContainerMetadata @@ -15,6 +14,7 @@ from .module import Module from .network import get_public_ip from .prompt_types import KeyValueType, ReachableIpAddress, RegexType, VersionType +from .service_response import CONTAINER_START_FAILURE_MESSAGE, ServiceResponse, StdoutStatus from .settings import Settings from .utils import ( deep_merge_dicts, @@ -35,7 +35,9 @@ "CommandGroup", "CommandOption", "StdoutSeverity", - "StdoutType", + "StdoutStatus", + "ServiceResponse", + "CONTAINER_START_FAILURE_MESSAGE", "Container", "ContainerManager", "ImageManager", diff --git a/das-cli/src/common/command.py b/das-cli/src/common/command.py index c681f32f..f734352d 100644 --- a/das-cli/src/common/command.py +++ b/das-cli/src/common/command.py @@ -1,9 +1,8 @@ import json import sys from contextlib import suppress -from dataclasses import asdict, dataclass from enum import Enum -from typing import Any, Callable, Dict, List, Optional, TypedDict +from typing import List, Optional, TypedDict, Union import click import yaml @@ -16,6 +15,7 @@ from common.exceptions import InvalidRemoteConfiguration from common.execution_context import ExecutionContext, SSHParams from common.prompt_types import ValidUsername +from common.service_response import ServiceResponse, StdoutStatus from common.utils import log_exception from settings.config import SECRETS_PATH @@ -27,11 +27,6 @@ class SelectOption(TypedDict): value: str -class StdoutType(Enum): - DEFAULT = "default" - MACHINE_READABLE = "machine_readable" - - class StdoutSeverity(Enum): ERROR = "red" WARNING = "yellow" @@ -51,24 +46,12 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) -@dataclass -class OutputBufferEntry: - message: Any - stdout_type: StdoutType = StdoutType.DEFAULT - severity: StdoutSeverity = StdoutSeverity.INFO - new_line: bool = True - - def to_dict(self) -> dict: - return asdict(self) - - class Command: name = "unknown" help = "" short_help = "" params: List = [] aliases: List[str] = [] - _output_buffer: List[OutputBufferEntry] = [] exclude_params = [ "output_format", @@ -166,6 +149,7 @@ def output_format(self): def __init__(self) -> None: self._execution_context: Optional[ExecutionContext] = None + self._structured_error_emitted = False self.command = click.Command( name=self.name, callback=self.safe_run, @@ -312,6 +296,27 @@ def _check_remote_config(self, remote_kwargs): "Remote configuration file does not match the local configuration file." ) + def _echo_remote_streams(self, result) -> None: + if result.stdout: + click.echo(result.stdout, nl=False) + if not result.stdout.endswith("\n"): + click.echo() + if result.stderr: + click.echo(result.stderr, nl=False, err=True) + if not result.stderr.endswith("\n"): + click.echo(err=True) + + @staticmethod + def _remote_das_cli_missing(result) -> bool: + combined = f"{result.stdout or ''}\n{result.stderr or ''}".lower() + if "das-cli" not in combined and "das_cli" not in combined: + return False + + return ( + "command not found" in combined + or "no such file or directory" in combined + ) + def _remote_run(self, kwargs, remote_kwargs): prefix = "das-cli" @@ -331,40 +336,35 @@ def _remote_run(self, kwargs, remote_kwargs): command = f"{prefix} {command_path} {extra_args} {remote_context}".strip() try: - if "config" not in command_path: self._check_remote_config(remote_kwargs) - # Ignores this check when a config command is called, prevents command from breaking when user is setting up configuration across multiple remote machines. - Connection(**remote_kwargs).run(command, pty=False) - - except Exception as e: + result = Connection(**remote_kwargs).run(command, hide=True, warn=True) + self._echo_remote_streams(result) - self.stdout( - str(e), - stdout_type=StdoutType.MACHINE_READABLE, - severity=StdoutSeverity.ERROR, - ) - - if isinstance(e, UnexpectedExit): - print(e) + if result.failed: + if self._remote_das_cli_missing(result): + self.stdout( + "[ERROR] das-cli is missing on the remote machine. Verify the installation.", + severity=StdoutSeverity.ERROR, + ) + raise UnexpectedExit(result) - msg_missing = ( - "[ERROR] das-cli is missing on the remote machine. Verify the installation." - ) - self.stdout(msg_missing, severity=StdoutSeverity.ERROR) - self.stdout( - msg_missing, - stdout_type=StdoutType.MACHINE_READABLE, - severity=StdoutSeverity.ERROR, - ) - else: - self.stdout(f"[ERROR] {e}", severity=StdoutSeverity.ERROR) + except UnexpectedExit: + raise + except Exception as e: + self.stdout(f"[ERROR] {e}", severity=StdoutSeverity.ERROR) + raise - raise e + def run_subcommand(self, subcommand: "Command", *args, **kwargs) -> None: + subcommand._structured_error_emitted = False + subcommand.run(*args, **kwargs) + if subcommand._structured_error_emitted: + self._structured_error_emitted = True def safe_run(self, **kwargs): remote, remote_kwargs = self._get_remote_kwargs_from_context() + self._structured_error_emitted = False for param in getattr(self, "exclude_params", []): setattr(self, f"_{param}", kwargs.pop(param, None)) @@ -380,6 +380,8 @@ def safe_run(self, **kwargs): if not remote: self.flush_stdout() + if self._structured_error_emitted: + raise click.exceptions.Exit(1) @staticmethod def select(text: str, options: dict[str, str], default: Optional[str] = None) -> str: @@ -431,97 +433,79 @@ def prompt( def confirm(text: str, **kwarg): return click.confirm(text=text, **kwarg) - def _handle_default_output(self, entry: OutputBufferEntry, stream_mode=False) -> None: - if self.output_format == "plain": - self._print_colored(entry.message, entry.severity, entry.new_line) + @staticmethod + def _payload_indicates_error(payload: dict) -> bool: + status = payload.get("status") + if isinstance(status, StdoutStatus): + return status == StdoutStatus.ERROR + if status is None: + return False + return str(status).lower() == StdoutStatus.ERROR.value + + def _handle_output(self, output_object, severity, new_line): + if isinstance(output_object, dict): + payload = output_object + message = payload.get("message", str(payload)) + else: + payload = dict(output_object) + message = output_object.message + + if self._payload_indicates_error(payload): + self._structured_error_emitted = True - def _handle_machine_readable_output( - self, - entry: OutputBufferEntry, - stream_mode: bool = False, - ) -> None: if self.output_format == "plain": - return + self._print_colored(message, severity, new_line) - if stream_mode: - if self.output_format == "json": - click.echo(json.dumps(entry.message), nl=True) - sys.stdout.flush() - elif self.output_format == "yaml": - click.echo( - yaml.dump(entry.message, sort_keys=False), - nl=True, - ) - sys.stdout.flush() - return - self._output_buffer.append(entry) + elif self.output_format == "json": + click.echo(json.dumps(payload), nl=True) + sys.stdout.flush() + + elif self.output_format == "yaml": + click.echo(yaml.dump(payload, sort_keys=False), nl=False) + sys.stdout.flush() + + def log(self, message: str, severity: StdoutSeverity = StdoutSeverity.INFO) -> None: + self._print_colored(message, severity, new_line=True, err=True) def stdout( self, - content: Any, - stdout_type: StdoutType = StdoutType.DEFAULT, + content: Union[str, ServiceResponse, dict], severity: StdoutSeverity = StdoutSeverity.INFO, new_line: bool = True, - stream_mode: bool = False, ) -> None: - entry = OutputBufferEntry( - message=content, - stdout_type=stdout_type, - severity=severity, - new_line=new_line, - ) + if isinstance(content, str): + if self.output_format == "plain": + self._print_colored(content, severity, new_line) + return - handlers: Dict[StdoutType, Callable[[OutputBufferEntry, bool], None]] = { - StdoutType.DEFAULT: self._handle_default_output, - StdoutType.MACHINE_READABLE: self._handle_machine_readable_output, - } + self._handle_output(content, severity, new_line) - handler = handlers.get(stdout_type, self._handle_default_output) - handler(entry, stream_mode) + def flush_stdout(self): + pass def run(self, *args, **kwargs): raise NotImplementedError( f"The 'run' method from the command '{self.name}' should be implemented." ) - def _flush_default_output(self): - for entry in self._output_buffer: - if entry.stdout_type == StdoutType.DEFAULT: - self._print_colored(entry.message, entry.severity) - - def flush_stdout(self): - if self.output_format == "plain": - self._flush_default_output() - elif self.output_format in {"json", "yaml"}: - self._flush_machine_readable_output() - self._output_buffer.clear() - - def _flush_machine_readable_output(self): - results = [ - entry.message - for entry in self._output_buffer - if entry.stdout_type == StdoutType.MACHINE_READABLE - ] - if not results: - return - - if self.output_format == "json": - click.echo(json.dumps(results, indent=2)) - elif self.output_format == "yaml": - click.echo(yaml.dump(results, sort_keys=False)) - - def _print_colored(self, text: str, severity: StdoutSeverity, new_line: bool = True) -> None: + def _print_colored( + self, + text: str, + severity: StdoutSeverity, + new_line: bool = True, + *, + err: bool = False, + ) -> None: fg_map = { StdoutSeverity.SUCCESS: "green", StdoutSeverity.ERROR: "red", StdoutSeverity.WARNING: "yellow", - StdoutSeverity.INFO: None, } fg = fg_map.get(severity) if fg: - click.secho(text, fg=fg, nl=new_line) + click.secho(text, fg=fg, nl=new_line, err=err) else: - click.echo(text, nl=new_line) + click.echo(text, nl=new_line, err=err) class CommandGroup(Command): diff --git a/das-cli/src/common/container_manager/atomdb/mongodb_container_manager.py b/das-cli/src/common/container_manager/atomdb/mongodb_container_manager.py index 0fc71255..f9a09f31 100644 --- a/das-cli/src/common/container_manager/atomdb/mongodb_container_manager.py +++ b/das-cli/src/common/container_manager/atomdb/mongodb_container_manager.py @@ -35,10 +35,12 @@ def __init__( self._options = options def _upload_key_to_server(self, cluster_node, mongodb_cluster_secret_key): + host = cluster_node.get("host") or cluster_node.get("ip") + username = cluster_node["username"] keyfile_server_path = f"/tmp/{get_rand_token(num_bytes=5)}.txt" try: - with ssh.open(cluster_node["host"], cluster_node["username"]) as ( + with ssh.open(host, username) as ( ssh_conn, sftp_conn, ): @@ -55,7 +57,7 @@ def _upload_key_to_server(self, cluster_node, mongodb_cluster_secret_key): except Exception as e: raise RuntimeError( - f"Failed to upload key to server at {cluster_node['host']} (username: {cluster_node['username']}): {e}" + f"Failed to upload key to server at {host} (username: {username}): {e}" ) def _get_cluster_node_config(self, cluster_node, mongodb_cluster_secret_key): diff --git a/das-cli/src/common/decorators.py b/das-cli/src/common/decorators.py index b713f033..7d3bf00c 100644 --- a/das-cli/src/common/decorators.py +++ b/das-cli/src/common/decorators.py @@ -5,8 +5,9 @@ from common.config.store import JsonConfigStore from settings.config import SECRETS_PATH -from .command import StdoutSeverity, StdoutType +from .command import StdoutSeverity from .docker.exceptions import DockerContainerNotFoundError +from .service_response import ServiceResponse, StdoutStatus from .settings import Settings LOCAL_HOSTS = { @@ -102,7 +103,7 @@ def _check_container( verbose: bool, ) -> bool: - name = container_status.get("container_name") + name = container_status.get("container_name", "") image = container_status.get("image") running = container_status.get("running", False) healthy = container_status.get("healthy", False) @@ -119,21 +120,17 @@ def _check_container( else: self.stdout( - f"{name} is not running on port {port}", + ServiceResponse( + service=name, + action="check", + status=StdoutStatus.ERROR, + message=f"{name} is not running on port {port}", + image=image, + port=port, + ), severity=StdoutSeverity.ERROR, ) - self.stdout( - { - "service": name, - "action": "check", - "status": "not_running", - "image": image, - "port": port, - }, - stdout_type=StdoutType.MACHINE_READABLE, - ) - return is_ok diff --git a/das-cli/src/common/service_response.py b/das-cli/src/common/service_response.py index b27a1966..fde6d8c9 100644 --- a/das-cli/src/common/service_response.py +++ b/das-cli/src/common/service_response.py @@ -1,18 +1,28 @@ from datetime import datetime -from typing import Optional +from enum import Enum +from typing import Any, Optional from .docker.container_manager import Container +class StdoutStatus(Enum): + ERROR = "error" + SUCCESS = "success" + INFO = "info" + + +CONTAINER_START_FAILURE_MESSAGE = "DAS-CLI failed to instantiate a container of this service." + + class ServiceResponse: def __init__( self, service: str, action: str, - status: str, + status: StdoutStatus | str, message: str | tuple[str], container: Optional[Container] = None, - error: Optional[dict] = None, + error: Any = None, **extra_details, ): self.service = service @@ -24,16 +34,30 @@ def __init__( self.error = error self.extra_details = extra_details + @staticmethod + def _serialize_status(status: StdoutStatus | str) -> str: + if isinstance(status, Enum): + return status.value + return status + + @staticmethod + def _serialize_error(error: Any) -> Any: + if isinstance(error, dict): + return error + if isinstance(error, Exception): + return {"type": type(error).__name__, "message": str(error)} + return {"message": str(error)} + def __iter__(self): details = {"container": dict(self.container) if self.container else None} details.update(self.extra_details) yield "service", self.service yield "action", self.action - yield "status", self.status + yield "status", self._serialize_status(self.status) yield "message", self.message yield "timestamp", self.timestamp yield "details", details if self.error: - yield "error", self.error + yield "error", self._serialize_error(self.error) diff --git a/das-cli/tests/integration/libs/errors.bash b/das-cli/tests/integration/libs/errors.bash index d64c10e5..47d8d654 100644 --- a/das-cli/tests/integration/libs/errors.bash +++ b/das-cli/tests/integration/libs/errors.bash @@ -1,4 +1,5 @@ export FILE_NOT_FOUND_ERROR="[FileNotFoundError] Configuration file not found at" export VALUE_ERROR_MSG="[ValueError] Your configuration file doesn't have all the entries this version of das-cli requires. You can call 'das-cli config set' and hit to every prompt in order to re-use the configuration you currently have in your config file and set the new ones to safe default values." export DOCKER_CONTAINER_MISSING="[DockerContainerNotFoundError]" -export PORT_IN_USE_ERROR="[PortBindingError] Port on localhost are already in use." \ No newline at end of file +export PORT_IN_USE_ERROR="[PortBindingError] Port on localhost are already in use." +export CONTAINER_START_FAILURE_MESSAGE="DAS-CLI failed to instantiate a container of this service." \ No newline at end of file diff --git a/das-cli/tests/integration/test_atomdb_broker.bats b/das-cli/tests/integration/test_atomdb_broker.bats index be17afe8..d4b7a3f5 100644 --- a/das-cli/tests/integration/test_atomdb_broker.bats +++ b/das-cli/tests/integration/test_atomdb_broker.bats @@ -50,8 +50,9 @@ teardown() { run das-cli atomdb-broker start - assert_output "Starting AtomDB Broker service... -[PortBindingError] Port ${atomdb_broker_port} on localhost are already in use." + assert_failure 1 + assert_output --partial "Starting AtomDB Broker service..." + assert_output --partial "$CONTAINER_START_FAILURE_MESSAGE" run stop_listen_port "${atomdb_broker_port}" assert_success @@ -163,4 +164,4 @@ AtomDB Broker started on port ${atomdb_broker_port}" run is_service_up das-atomdb-broker-40007 assert_success -} \ No newline at end of file +} diff --git a/das-cli/tests/integration/test_attention_broker.bats b/das-cli/tests/integration/test_attention_broker.bats index 989c609e..d1c4979e 100644 --- a/das-cli/tests/integration/test_attention_broker.bats +++ b/das-cli/tests/integration/test_attention_broker.bats @@ -32,8 +32,10 @@ setup() { assert_success run das-cli attention-broker start - assert_output "Starting Attention Broker service... -[PortBindingError] Port ${attention_broker_port} on localhost are already in use." + + assert_failure 1 + assert_output --partial "Starting Attention Broker service..." + assert_output --partial "$CONTAINER_START_FAILURE_MESSAGE" run stop_listen_port "${attention_broker_port}" assert_success @@ -134,4 +136,4 @@ Attention Broker started on port ${attention_broker_port}" run is_service_up das-attention-broker-40001 assert_success -} \ No newline at end of file +} diff --git a/das-cli/tests/integration/test_command_router.bats b/das-cli/tests/integration/test_command_router.bats index afc9daa0..ca574f63 100644 --- a/das-cli/tests/integration/test_command_router.bats +++ b/das-cli/tests/integration/test_command_router.bats @@ -51,8 +51,9 @@ teardown() { run das-cli command-router start - assert_output "Starting Command Router service... -[PortBindingError] Port ${port} on localhost are already in use." + assert_failure 1 + assert_output --partial "Starting Command Router service..." + assert_output --partial "$CONTAINER_START_FAILURE_MESSAGE" run stop_listen_port "${port}" assert_success @@ -163,4 +164,4 @@ Command Router started on port ${port}" run is_service_up das-command-router-40008 assert_success -} \ No newline at end of file +} diff --git a/das-cli/tests/integration/test_context_broker.bats b/das-cli/tests/integration/test_context_broker.bats index 393c974f..86c94729 100644 --- a/das-cli/tests/integration/test_context_broker.bats +++ b/das-cli/tests/integration/test_context_broker.bats @@ -105,9 +105,9 @@ teardown() { run das-cli context-broker start \ --port-range 12700:12800 - assert_output --partial "[PortBindingError]" - assert_output --partial "Port ${context_broker_port}" - assert_output --partial "already in use" + assert_failure 1 + assert_output --partial "Starting Context Broker service" + assert_output --partial "$CONTAINER_START_FAILURE_MESSAGE" run stop_listen_port "${context_broker_port}" assert_success diff --git a/das-cli/tests/integration/test_evolution_agent.bats b/das-cli/tests/integration/test_evolution_agent.bats index a5ddcca0..e8735405 100644 --- a/das-cli/tests/integration/test_evolution_agent.bats +++ b/das-cli/tests/integration/test_evolution_agent.bats @@ -9,15 +9,16 @@ load 'libs/errors' setup() { use_config "simple" - das-cli db start - das-cli attention-broker start - das-cli query-agent start --port-range 12000:12100 + das-cli db start || true + das-cli attention-broker start || true + das-cli query-agent start --port-range 12000:12100 || true das-cli evolution-agent stop &>/dev/null || true - local evolution_agent_port evolution_agent_port="$(extract_port "$(get_config .agents.evolution.endpoint)")" stop_listen_port "$evolution_agent_port" &>/dev/null || true + + service_name="das-evolution-agent-40005" } teardown() { @@ -64,10 +65,10 @@ teardown() { assert_output --partial "$DOCKER_CONTAINER_MISSING" assert_output --partial "Please start the required services" - run is_service_up query_agent + run is_service_up das-query-engine-40002 assert_failure - run is_service_up das-evolution-agent-40005 + run is_service_up "$service_name" assert_failure } @@ -84,13 +85,14 @@ teardown() { run das-cli evolution-agent start \ --port-range 12700:12800 - assert_output --partial "[PortBindingError]" - assert_output --partial "already in use" + assert_failure 1 + assert_output --partial "Starting Evolution Agent service" + assert_output --partial "$CONTAINER_START_FAILURE_MESSAGE" run stop_listen_port "$evolution_agent_port" assert_success - run is_service_up das-evolution-agent-40005 + run is_service_up "$service_name" assert_failure } @@ -108,32 +110,23 @@ teardown() { assert_output --partial "already running" - run is_service_up das-evolution-agent-40005 + run is_service_up "$service_name" assert_success } @test "Starting the Evolution Agent" { - local evolution_agent_port - evolution_agent_port="$(extract_port "$(get_config .agents.evolution.endpoint)")" - - local query_agent_port - query_agent_port="$(extract_port "$(get_config ".agents.query.endpoint")")" - run das-cli evolution-agent start \ --port-range 12700:12800 assert_success - assert_output --partial "started on port" + assert_output --partial "started listening on the ports" assert_output --partial "$evolution_agent_port" - run is_service_up das-evolution-agent-40005 + run is_service_up "$service_name" assert_success } @test "Stopping the Evolution Agent when it's up-and-running" { - local query_agent_port - query_agent_port="$(extract_port "$(get_config ".agents.query.endpoint")")" - das-cli evolution-agent start \ --port-range 12700:12800 @@ -141,7 +134,7 @@ teardown() { assert_output --partial "service stopped" - run is_service_up das-evolution-agent-40005 + run is_service_up "$service_name" assert_failure } @@ -150,17 +143,11 @@ teardown() { assert_output --partial "already stopped" - run is_service_up das-evolution-agent-40005 + run is_service_up "$service_name" assert_failure } @test "Restarting the Evolution Agent when it's up-and-running" { - local evolution_agent_port - evolution_agent_port="$(extract_port "$(get_config .agents.evolution.endpoint)")" - - local query_agent_port - query_agent_port="$(extract_port "$(get_config ".agents.query.endpoint")")" - das-cli evolution-agent start \ --port-range 12700:12800 @@ -171,24 +158,18 @@ teardown() { assert_output --partial "Starting Evolution Agent service" assert_output --partial "$evolution_agent_port" - run is_service_up das-evolution-agent-40005 + run is_service_up "$service_name" assert_success } @test "Restarting the Evolution Agent when it's not up" { - local evolution_agent_port - evolution_agent_port="$(extract_port "$(get_config .agents.evolution.endpoint)")" - - local query_agent_port - query_agent_port="$(extract_port "$(get_config ".agents.query.endpoint")")" - run das-cli evolution-agent restart \ --port-range 12700:12800 assert_output --partial "already stopped" - assert_output --partial "started on port" + assert_output --partial "started listening on the ports" assert_output --partial "$evolution_agent_port" - run is_service_up das-evolution-agent-40005 + run is_service_up "$service_name" assert_success } \ No newline at end of file diff --git a/das-cli/tests/integration/test_inference_agent.bats b/das-cli/tests/integration/test_inference_agent.bats index 1b5e3aed..93302a11 100644 --- a/das-cli/tests/integration/test_inference_agent.bats +++ b/das-cli/tests/integration/test_inference_agent.bats @@ -9,15 +9,13 @@ load 'libs/errors' setup() { use_config "simple" - das-cli attention-broker start - das-cli db start + das-cli db start || true + das-cli attention-broker start || true + das-cli query-agent start --port-range 12000:12100 || true das-cli inference-agent stop &>/dev/null || true inference_agent_port="$(extract_port "$(get_config .agents.inference.endpoint)")" - query_agent_port="$(extract_port "$(get_config .agents.query.endpoint)")" - - stop_listen_port "$inference_agent_port" &>/dev/null || true service_name="das-inference-agent-40004" @@ -25,6 +23,7 @@ setup() { teardown() { das-cli inference-agent stop &>/dev/null || true + das-cli query-agent stop &>/dev/null || true das-cli attention-broker stop &>/dev/null || true } @@ -54,8 +53,8 @@ teardown() { assert_output --partial "$FILE_NOT_FOUND_ERROR" } -@test "Start Inference Agent when Attention Broker is not up" { - das-cli attention-broker stop +@test "Start Inference Agent when Query Agent is not up" { + das-cli query-agent stop run das-cli inference-agent start \ --port-range 12500:12600 @@ -63,7 +62,7 @@ teardown() { assert_output --partial "$DOCKER_CONTAINER_MISSING" assert_output --partial "Please start the required services" - run is_service_up das-attention-broker-40001 + run is_service_up das-query-engine-40002 assert_failure run is_service_up "$service_name" @@ -77,8 +76,9 @@ teardown() { run das-cli inference-agent start \ --port-range 12500:12600 - assert_output --partial "[PortBindingError]" - assert_output --partial "already in use" + assert_failure 1 + assert_output --partial "Starting Inference Agent service" + assert_output --partial "$CONTAINER_START_FAILURE_MESSAGE" run stop_listen_port "$inference_agent_port" assert_success @@ -88,7 +88,6 @@ teardown() { } @test "Starting the Inference Agent when it's already up" { - # garante que subiu run das-cli inference-agent start \ --port-range 12500:12600 assert_success @@ -160,4 +159,4 @@ teardown() { run is_service_up "$service_name" assert_success -} \ No newline at end of file +} diff --git a/das-cli/tests/integration/test_link_creation_agent.bats b/das-cli/tests/integration/test_link_creation_agent.bats index e25ea0be..a6c11f10 100644 --- a/das-cli/tests/integration/test_link_creation_agent.bats +++ b/das-cli/tests/integration/test_link_creation_agent.bats @@ -83,9 +83,9 @@ teardown() { run das-cli link-creation-agent start \ --port-range 12300:12400 - assert_output --partial "[PortBindingError]" - assert_output --partial "already in use" - assert_output --partial "${link_creation_agent_port}" + assert_failure 1 + assert_output --partial "Starting Link Creation Agent service" + assert_output --partial "$CONTAINER_START_FAILURE_MESSAGE" run stop_listen_port "${link_creation_agent_port}" assert_success diff --git a/das-cli/tests/integration/test_metta.bats b/das-cli/tests/integration/test_metta.bats index 39aaab8c..ad09efbe 100644 --- a/das-cli/tests/integration/test_metta.bats +++ b/das-cli/tests/integration/test_metta.bats @@ -34,7 +34,9 @@ setup() { run das-cli metta check "$metta_file_path" + assert_failure 1 assert_line --partial "Checking syntax... FAILED" + assert_output --partial "MeTTa syntax check failed" } @test "Checking syntax of multiple MeTTa files" { @@ -42,9 +44,11 @@ setup() { run das-cli metta check "$metta_file_path" + assert_failure 1 assert_line --partial "$metta_file_path" assert_line --partial "Checking syntax... OK" assert_line --partial "Checking syntax... FAILED" + assert_output --partial "MeTTa syntax check failed" } @test "Checking MeTTa file with invalid path" { @@ -79,9 +83,11 @@ setup() { run das-cli metta load "$metta_file_path" + assert_failure 1 assert_line --partial "Loading metta file" assert_line --partial "$metta_file_path" - assert_line --partial "contains invalid MeTTa syntax." + assert_output --partial "MeTTa load failed" + assert_output --partial "contains invalid MeTTa syntax." } @test "Loading a valid MeTTa file" { @@ -102,11 +108,12 @@ setup() { run das-cli metta load "$metta_file_path" + assert_failure 1 assert_line --partial "Loading metta file" assert_line --partial "animals.metta" assert_line --partial "invalid.metta" - assert_line --partial "Failed loading file." - assert_line --partial "The file contains invalid MeTTa syntax." + assert_output --partial "MeTTa load failed" + assert_output --partial "contains invalid MeTTa syntax." } @test "Trying to load a MeTTa file with an invalid path" { @@ -126,6 +133,7 @@ setup() { run das-cli metta load "$metta_file_path" + assert_failure 1 assert_line --partial "is not running on port" assert_line --partial "Please use 'db start'" } @@ -137,6 +145,7 @@ setup() { run das-cli metta load "$metta_file_path" + assert_failure 1 assert_line --partial "does not have correct permissions." chmod +r "$metta_file_path" diff --git a/das-cli/tests/integration/test_query_agent.bats b/das-cli/tests/integration/test_query_agent.bats index 8c2a7667..91159db4 100644 --- a/das-cli/tests/integration/test_query_agent.bats +++ b/das-cli/tests/integration/test_query_agent.bats @@ -89,9 +89,9 @@ teardown() { run das-cli query-agent start --port-range 12000:12100 - assert_output --partial "[PortBindingError]" - assert_output --partial "Port ${query_agent_port}" - assert_output --partial "already in use" + assert_failure 1 + assert_output --partial "Starting Query Agent service" + assert_output --partial "$CONTAINER_START_FAILURE_MESSAGE" run stop_listen_port "${query_agent_port}" assert_success diff --git a/das-dashboard/backend/controllers/container_controllers.py b/das-dashboard/backend/controllers/container_controllers.py index 6a58b31f..96c5a05a 100644 --- a/das-dashboard/backend/controllers/container_controllers.py +++ b/das-dashboard/backend/controllers/container_controllers.py @@ -43,12 +43,13 @@ def stop_orchestration(services: list[str]): ) @router.post("/atomdb/start") -def start_databases(): +def start_databases(host: str | None = None): result = CONTAINER_SERVICES.manage_container( container_name=None, command="db", action=ActionTypes.START, + host=host, ) @@ -61,12 +62,13 @@ def start_databases(): ) @router.post("/atomdb/stop") -def stop_databases(): +def stop_databases(host: str | None = None): result = CONTAINER_SERVICES.manage_container( container_name=None, command="db", action=ActionTypes.STOP, + host=host, ) return JSONResponse( diff --git a/das-dashboard/backend/controllers/query_controllers.py b/das-dashboard/backend/controllers/query_controllers.py index ff85fc14..8b11bf3b 100644 --- a/das-dashboard/backend/controllers/query_controllers.py +++ b/das-dashboard/backend/controllers/query_controllers.py @@ -98,7 +98,12 @@ async def get_execution_stream(websocket: WebSocket, execution_id: str): except WebSocketDisconnect: pass except CommandRouterConnectionError as error: - await _safe_send_error(websocket, execution_id, error.message) + await _safe_send_error( + websocket, + execution_id, + error.message, + details=getattr(error, "detail", None), + ) except json.JSONDecodeError: await _safe_send_error( websocket, @@ -107,11 +112,22 @@ async def get_execution_stream(websocket: WebSocket, execution_id: str): ) -async def _safe_send_error(websocket: WebSocket, execution_id: str, message: str) -> None: +async def _safe_send_error( + websocket: WebSocket, + execution_id: str, + message: str, + *, + details: str | None = None, +) -> None: try: - await websocket.send_json( - {"execution_id": execution_id, "status": "error", "message": message} - ) + payload = { + "execution_id": execution_id, + "status": "error", + "message": message, + } + if details: + payload["details"] = details + await websocket.send_json(payload) except (WebSocketDisconnect, RuntimeError): pass diff --git a/das-dashboard/backend/services/config_services.py b/das-dashboard/backend/services/config_services.py index 5217ae1a..959bdfd2 100644 --- a/das-dashboard/backend/services/config_services.py +++ b/das-dashboard/backend/services/config_services.py @@ -11,7 +11,7 @@ from shared.mappers.das_config_mapper import ConfigMapper from shared.mappers.nested_config_mapper import NestedConfigMapper from shared.builders.atom_db_builder import AtomDbBuilder -from shared.exceptions.custom_exceptions import ConfigurationFileLoadError +from shared.exceptions.custom_exceptions import ConfigurationFileLoadError, RemoteSshTransferError from shared.utils.das_cli_config import set_das_cli_config from shared.utils.flat_config_utils import merge_flat_config from shared.utils.remote_scp import RemoteScpService @@ -47,8 +47,9 @@ async def save_config(self, configuration_entries: ConfigurationEntriesDto) -> d message = "Configuration saved successfully." if remote_hosts: - hosts_label = ", ".join(remote_hosts) - message = f"{message} Propagated to remote host(s): {hosts_label}." + username, _ = self.remote_scp.ensure_profile() + destinations = ", ".join(f"{username}@{host}" for host in remote_hosts) + message = f"{message} Copied to remote host(s): {destinations}." return { "message": message, @@ -95,6 +96,13 @@ def _scp_config_sync(self, ip: str, nested: dict) -> str: remote_dir=remote_dir, ssh=ssh, ) + + if not self.remote_scp.remote_file_exists(ssh, remote_path): + raise RemoteSshTransferError( + f"Configuration file was not found on {ip} after transfer.", + detail=f"Expected path: {remote_path}", + ) + return remote_path finally: if ssh is not None: diff --git a/das-dashboard/backend/services/container_services.py b/das-dashboard/backend/services/container_services.py index 4e67383c..79e7e599 100644 --- a/das-dashboard/backend/services/container_services.py +++ b/das-dashboard/backend/services/container_services.py @@ -1,6 +1,3 @@ -import subprocess -import json -import re from concurrent.futures import ThreadPoolExecutor, as_completed from shared.enums.action_types import ActionTypes @@ -9,7 +6,10 @@ from shared.exceptions.custom_exceptions import ( ConfigurationValueNotFoundError, DasCliCommandException, - DASCLIResponseDecodeError, +) +from shared.utils.das_cli_response import ( + DEFAULT_CLI_ERROR_MESSAGE, + run_das_cli_json_command, ) from shared.utils.service_inventory import ORCHESTRATION_ORDER @@ -17,7 +17,6 @@ class ContainerServices: _SKIP_ERROR_MARKERS = ("No such command",) - _ANSI_ESCAPE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") def __init__(self, web_config: WebConfiguration): self.web_config = web_config @@ -27,11 +26,12 @@ def manage_container( action: ActionTypes, container_name: str = None, command: str = None, + host: str | None = None, ): service_command = command or container_name - host = self._resolve_service_host(service_command) + resolved_host = host or self._resolve_service_host(service_command) generated_command = self.build_das_cli_command( - host=host, + host=resolved_host, service_command=service_command, action=action.value, ) @@ -94,7 +94,10 @@ def _orchestrate_local(self, commands: dict) -> list: errors.append(outcome["error"]) if errors: - raise DasCliCommandException(" | ".join(errors)) + raise DasCliCommandException( + message="One or more services failed.", + detail="\n".join(errors), + ) return results @@ -116,7 +119,10 @@ def _orchestrate_remote(self, commands: dict) -> list: errors.append(outcome["error"]) if errors: - raise DasCliCommandException(" | ".join(errors)) + raise DasCliCommandException( + message="One or more services failed.", + detail="\n".join(errors), + ) return results @@ -133,9 +139,13 @@ def _run_service_command(self, service_name: str, cmd: list) -> dict: "service": service_name, "reason": detail, } - return {"success": False, "error": f"Service {service_name} failed: {detail}"} - except DASCLIResponseDecodeError as exc: - return {"success": False, "error": f"Service {service_name} failed: {exc}"} + error_message = exc.message or DEFAULT_CLI_ERROR_MESSAGE + error_detail = exc.detail or detail + if error_detail and error_detail != error_message: + error_text = f"Service {service_name} failed: {error_message}\n{error_detail}" + else: + error_text = f"Service {service_name} failed: {error_message}" + return {"success": False, "error": error_text} except Exception as exc: detail = str(exc) or exc.__class__.__name__ return {"success": False, "error": f"Service {service_name} failed: {detail}"} @@ -165,79 +175,27 @@ def build_das_cli_command( return cmd def run_das_cli_command(self, command: list): - result = None try: - result = subprocess.run( + stdout_json = run_das_cli_json_command( command, - capture_output=True, - text=True, - check=True, + default_message=DEFAULT_CLI_ERROR_MESSAGE, ) - stdout_json = self._parse_das_cli_stdout(result.stdout) - return { "success": True, "stdout": stdout_json, - "stderr": result.stderr, + "stderr": "", "command": command, } - except subprocess.CalledProcessError as e: - error_output = self._clean_cli_output(e.stderr or e.stdout or "Unknown Subprocess Error") - raise DasCliCommandException(error_output) - - except json.JSONDecodeError as e: - output = self._clean_cli_output(result.stdout if result else "") - if result is not None and not output: - return { - "success": True, - "stdout": None, - "stderr": result.stderr, - "command": command, - } - - raise DasCliCommandException( - f"Could not parse das-cli output as JSON: {output or '(empty)'}" - ) from e - except DasCliCommandException: raise except Exception as e: - details = str(e) or e.__class__.__name__ - - raise DasCliCommandException(details) - - def _parse_das_cli_stdout(self, stdout: str): - cleaned = self._ANSI_ESCAPE.sub("", stdout.strip()) - if not cleaned: - raise json.JSONDecodeError("Empty das-cli output", "", 0) - - parsers = ( - lambda text: json.loads(text), - lambda text: json.loads(text.replace("\n", "")), - ) - - for parse in parsers: - try: - return parse(cleaned) - except json.JSONDecodeError: - continue - - for line in reversed(cleaned.splitlines()): - candidate = line.strip() - if not candidate.startswith(("{", "[")): - continue - try: - return json.loads(candidate) - except json.JSONDecodeError: - continue - - raise json.JSONDecodeError("No JSON payload in das-cli output", cleaned, 0) - - def _clean_cli_output(self, output: str) -> str: - return self._ANSI_ESCAPE.sub("", output.strip()) + raise DasCliCommandException( + message=DEFAULT_CLI_ERROR_MESSAGE, + detail=str(e) or e.__class__.__name__, + ) def _resolve_service_host(self, service_command: str) -> str: service_config = self.web_config.get_service_config(service_command) diff --git a/das-dashboard/backend/services/database_services.py b/das-dashboard/backend/services/database_services.py index 7fb9ff7c..d8ab1dd9 100644 --- a/das-dashboard/backend/services/database_services.py +++ b/das-dashboard/backend/services/database_services.py @@ -1,6 +1,4 @@ -import json import os -import subprocess from fastapi import UploadFile from scp import SCPClient, SCPException @@ -13,11 +11,11 @@ ) from shared.exceptions.custom_exceptions import ( FileAlreadyExistsException, - DasCliCommandException, RemoteSshTransferError, ) from shared.utils.remote_scp import RemoteScpService from shared.utils.upload_utils import safe_upload_filename +from shared.utils.das_cli_response import run_das_cli_json_command class DatabaseServices: @@ -91,24 +89,7 @@ def load_metta_file_into_db(self, host: str, metta_file_path: str): cmd.extend(["-o", "json"]) - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - check=True, - ) - - stdout_content = result.stdout - try: - stdout_content = json.loads(result.stdout) - except Exception: - stdout_content = result.stdout.replace("\n", "").strip() - - return stdout_content - - except subprocess.CalledProcessError as e: - error_output = (e.stderr or e.stdout or "Unknown Subprocess Error").strip() - raise DasCliCommandException(error_output) - except Exception as e: - raise DasCliCommandException(str(e)) + return run_das_cli_json_command( + cmd, + default_message="The das-cli metta load command failed.", + ) diff --git a/das-dashboard/backend/services/metrics_services.py b/das-dashboard/backend/services/metrics_services.py index e90edb46..022b5496 100644 --- a/das-dashboard/backend/services/metrics_services.py +++ b/das-dashboard/backend/services/metrics_services.py @@ -9,6 +9,12 @@ DasCliNotInstalledException, DasCliCommandException ) +from shared.utils.das_cli_response import ( + DEFAULT_CLI_ERROR_MESSAGE, + clean_cli_output, + parse_das_cli_stdout, + raise_from_cli_output, +) class MetricsServices: @@ -72,25 +78,26 @@ async def load_server_metrics(self, metric_scope: MetricScope, host: str): process = await self._run_async_process(host, stream=False) stdout, _ = await process.communicate() stdout_str = stdout.decode().strip() - cleaned_stdout = self.ansi_escape.sub("", stdout_str) + cleaned_stdout = clean_cli_output(stdout_str) if process.returncode and process.returncode != 0: - try: - parsed_err = json.loads(cleaned_stdout) - if isinstance(parsed_err, list) and parsed_err: - cleaned_stdout = parsed_err[0] - except Exception: - pass - raise DasCliCommandException(cleaned_stdout or "Unknown Remote Connection Error") + raise_from_cli_output( + cleaned_stdout, + default_message="Failed to load server metrics.", + exit_code=process.returncode, + ) try: - parsed_json = json.loads(cleaned_stdout) + parsed_json = parse_das_cli_stdout(cleaned_stdout) if isinstance(parsed_json, list) and parsed_json and isinstance(parsed_json[0], str): - raise DasCliCommandException(parsed_json[0]) + raise DasCliCommandException( + message=DEFAULT_CLI_ERROR_MESSAGE, + detail=parsed_json[0], + ) except json.JSONDecodeError: if "\n" in cleaned_stdout: cleaned_stdout = cleaned_stdout.split("\n")[-1] - parsed_json = json.loads(cleaned_stdout) + parsed_json = parse_das_cli_stdout(cleaned_stdout) return self._define_response_scope(metric_scope, parsed_json, host) diff --git a/das-dashboard/backend/services/query_services.py b/das-dashboard/backend/services/query_services.py index ff28c9dc..844bbecd 100644 --- a/das-dashboard/backend/services/query_services.py +++ b/das-dashboard/backend/services/query_services.py @@ -1,4 +1,5 @@ import json +import logging from collections.abc import AsyncIterator from concurrent.futures import ThreadPoolExecutor, as_completed @@ -17,6 +18,8 @@ VALID_COMMAND_TYPES = ("get", "set", "query") # Evolution will be disconsidered for now. ROUTE_PREFIX = "/command-router" +logger = logging.getLogger(__name__) + class QueryServices: def __init__(self, web_config: WebConfiguration): @@ -41,8 +44,10 @@ async def stream_execution_events(self, execution_id: str) -> AsyncIterator[dict query_db.save_answers_from_chunk(execution_id, payload) yield payload except (WebSocketException, RequestException) as error: + self._log_command_router_failure(websocket_url, error) raise CommandRouterConnectionError(endpoint=websocket_url, detail=str(error)) from error except OSError as error: + self._log_command_router_failure(websocket_url, error) raise CommandRouterConnectionError(endpoint=websocket_url, detail=str(error)) from error def get_query_status(self, execution_id: str) -> Response: @@ -149,8 +154,16 @@ def _call_http_proxy(self, method: str, path: str, **request_kwargs) -> Response try: return requests.request(method, url, timeout=5, **request_kwargs) except RequestException as error: + self._log_command_router_failure(url, error) raise CommandRouterConnectionError(endpoint=url, detail=str(error)) from error + def _log_command_router_failure(self, endpoint: str, error: Exception) -> None: + logger.warning( + "Command Router connection failed: endpoint=%s error=%s", + endpoint, + error, + ) + def _find_command_router_http_url(self) -> str: HTTP_PROXY_PORT = 40009 router = self.web_config.get_service_config("command-router") diff --git a/das-dashboard/backend/shared/exceptions/custom_exceptions.py b/das-dashboard/backend/shared/exceptions/custom_exceptions.py index 0a53d2e6..dda4bcc2 100644 --- a/das-dashboard/backend/shared/exceptions/custom_exceptions.py +++ b/das-dashboard/backend/shared/exceptions/custom_exceptions.py @@ -1,12 +1,38 @@ class DasCliCommandException(Exception): - - def __init__(self, stderror: str): - self.message = "There was an error while running das-cli." - self.stderror = stderror - super().__init__(stderror) + DEFAULT_MESSAGE = "There was an error while running das-cli." + + def __init__( + self, + message: str | None = None, + *, + detail: str | None = None, + stderror: str | None = None, + ): + if stderror is not None: + resolved_message = (message or "").strip() or self.DEFAULT_MESSAGE + resolved_detail = (detail or stderror).strip() + elif message is not None and detail is not None: + resolved_message = message.strip() or self.DEFAULT_MESSAGE + resolved_detail = detail.strip() + elif detail is not None: + resolved_message = self.DEFAULT_MESSAGE + resolved_detail = detail.strip() + elif message is not None: + resolved_message = message.strip() or self.DEFAULT_MESSAGE + resolved_detail = "" + else: + resolved_message = self.DEFAULT_MESSAGE + resolved_detail = "" + + self.message = resolved_message + self.detail = resolved_detail + self.stderror = resolved_detail or resolved_message + super().__init__(self.detail or self.message) def __str__(self) -> str: - return self.stderror + if self.detail and self.detail != self.message: + return f"{self.message}\n{self.detail}" + return self.message class DasCliNotInstalledException(Exception): @@ -96,19 +122,6 @@ class DASServiceInstantiationError(Exception): def __init__(self): self.message = "There was an error while trying to resolve this service. Command cannot be executed." -class DASCLIResponseDecodeError(Exception): - - def __init__(self, detail: str = ""): - self.message = ( - "DAS-CLI returned a message in a format the server could not read. " - "Try running the command manually to check the results." - ) - self.detail = detail - super().__init__(self.detail or self.message) - - def __str__(self) -> str: - return self.detail or self.message - class ConfigurationFileLoadError(Exception): def __init__(self, detail: str = ""): @@ -143,12 +156,23 @@ def __str__(self) -> str: return self.message class CommandRouterConnectionError(Exception): + MESSAGE = ( + "Could not reach the Command Router. " + "Check that the Command Router is running in your architecture " + "(start it from the Dashboard under Architecture or Services)." + ) + HINT = ( + "If it should already be running, verify the configured host and that port 40009 is reachable." + ) def __init__(self, endpoint: str, detail: str = ""): self.endpoint = endpoint - self.message = f"Could not reach the Command Router HTTP API at {endpoint}." - self.detail = detail - super().__init__(self.detail or self.message) + self.technical_detail = (detail or "").strip() + self.message = self.MESSAGE + self.detail = self.HINT + super().__init__(self.message) def __str__(self) -> str: - return self.detail or self.message \ No newline at end of file + if self.technical_detail: + return f"{self.message}\n{self.technical_detail}" + return self.message \ No newline at end of file diff --git a/das-dashboard/backend/shared/exceptions/exception_handlers.py b/das-dashboard/backend/shared/exceptions/exception_handlers.py index 46844406..c76e41e9 100644 --- a/das-dashboard/backend/shared/exceptions/exception_handlers.py +++ b/das-dashboard/backend/shared/exceptions/exception_handlers.py @@ -7,7 +7,6 @@ FileSaveException, FileAlreadyExistsException, DASServiceInstantiationError, - DASCLIResponseDecodeError, RemoteSshConnectionError, RemoteSshTransferError, CustomValueError, @@ -45,12 +44,15 @@ async def handle_das_cli_command_error( request: Request, exc: DasCliCommandException ): + content = { + "message": exc.message or DasCliCommandException.DEFAULT_MESSAGE, + } + if exc.detail: + content["exceptionMessage"] = exc.detail + return JSONResponse( status_code=500, - content={ - "message": exc.stderror or "There was an error running this DAS CLI command.", - "exceptionMessage": exc.stderror - } + content=content, ) async def handle_das_cli_not_installed_error( @@ -130,7 +132,7 @@ async def handle_command_router_connection_error( request: Request, exc: CommandRouterConnectionError, ): - content = {"message": exc.message, "endpoint": exc.endpoint} + content = {"message": exc.message} if exc.detail: content["exceptionMessage"] = exc.detail @@ -156,19 +158,6 @@ async def handle_general_exception( } ) - async def das_cli_decode_error( - self, - request: Request, - exc: DASCLIResponseDecodeError - ): - - return JSONResponse( - status_code=500, - content={ - "message": exc.message - } - ) - async def handle_custom_value_error( self, request: Request, diff --git a/das-dashboard/backend/shared/utils/das_cli_config.py b/das-dashboard/backend/shared/utils/das_cli_config.py index 80636d13..370ef7fe 100644 --- a/das-dashboard/backend/shared/utils/das_cli_config.py +++ b/das-dashboard/backend/shared/utils/das_cli_config.py @@ -1,10 +1,16 @@ -import json import os -import subprocess +import json -from shared.exceptions.custom_exceptions import DasCliCommandException, DasCliNotInstalledException, ConfigurationFileLoadError +from shared.exceptions.custom_exceptions import ( + DasCliCommandException, + ConfigurationFileLoadError, +) from shared.internal.constants import DEFAULT_SSHKEY_CLONE_PATH, LOCAL_HOSTS from shared.internal.web_configuration import WebConfiguration +from shared.utils.das_cli_response import ( + DEFAULT_CLI_ERROR_MESSAGE, + run_das_cli_json_command, +) def _validate_config_file(file_path: str) -> None: @@ -52,7 +58,7 @@ def set_das_cli_config( if remote_host is None: _validate_config_file(cleaned_path) - cmd = ["das-cli", "config", "set", "--file", cleaned_path] + cmd = ["das-cli", "config", "set", "--file", cleaned_path, "-o", "json"] if remote_host is not None: profile = web_config.user_profile @@ -64,20 +70,15 @@ def set_das_cli_config( cmd.extend(["--remote", "--host", remote_host, "-u", ssh_username, "-k", ssh_key]) - try: - subprocess.run( - cmd, - capture_output=True, - text=True, - check=True, - timeout=30, + payload = run_das_cli_json_command( + cmd, + default_message=DEFAULT_CLI_ERROR_MESSAGE, + timeout=30, + ) + + details = payload.get("details") or {} + content = details.get("content") + if content is not None and not isinstance(content, dict): + raise DasCliCommandException( + message="The das-cli config response did not include a valid config object.", ) - except subprocess.TimeoutExpired as error: - raise DasCliCommandException("das-cli command timed out.") from error - except FileNotFoundError as error: - raise DasCliNotInstalledException("das-cli not found.") from error - except subprocess.CalledProcessError as error: - error_output = (error.stderr or error.stdout or "Unknown Subprocess Error").strip() - raise DasCliCommandException(error_output) from error - except Exception as error: - raise DasCliCommandException(str(error)) from error diff --git a/das-dashboard/backend/shared/utils/das_cli_response.py b/das-dashboard/backend/shared/utils/das_cli_response.py new file mode 100644 index 00000000..34b0fedd --- /dev/null +++ b/das-dashboard/backend/shared/utils/das_cli_response.py @@ -0,0 +1,360 @@ +import json +import logging +import re +import shlex +import subprocess +from typing import Any + +from shared.exceptions.custom_exceptions import ( + DasCliCommandException, + DasCliNotInstalledException, +) + +logger = logging.getLogger(__name__) + +ANSI_ESCAPE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") +UNEXPECTED_EXIT_BLOCK = re.compile( + r"\[UnexpectedExit\][\s\S]*?(?:Stderr:\s*already printed\s*|Stdout:\s*already printed\s*)", + re.IGNORECASE, +) +DEFAULT_CLI_ERROR_MESSAGE = "There was an error while running das-cli." +ERROR_STATUSES = {"error"} +DEFAULT_CLI_TIMEOUT_SECONDS = 120.0 + + +def clean_cli_output(output: str) -> str: + return ANSI_ESCAPE.sub("", (output or "").strip()) + + +def sanitize_cli_output_for_user(output: str) -> str: + cleaned = clean_cli_output(output) + if not cleaned: + return "" + + trimmed = UNEXPECTED_EXIT_BLOCK.sub("", cleaned).strip() + kept_lines: list[str] = [] + for line in trimmed.splitlines(): + stripped = line.strip() + if stripped in {"Stdout: already printed", "Stderr: already printed"}: + continue + kept_lines.append(line.rstrip()) + + compact: list[str] = [] + previous_blank = False + for line in kept_lines: + is_blank = not line.strip() + if is_blank and previous_blank: + continue + compact.append(line) + previous_blank = is_blank + + return "\n".join(compact).strip() + + +def parse_das_cli_stdout(stdout: str) -> dict[str, Any]: + cleaned = clean_cli_output(stdout) + if not cleaned: + raise json.JSONDecodeError("Empty das-cli output", "", 0) + + parsers = ( + lambda text: json.loads(text), + lambda text: json.loads(text.replace("\n", "")), + ) + + for parse in parsers: + try: + payload = parse(cleaned) + if isinstance(payload, dict): + return payload + if isinstance(payload, list): + for item in payload: + if isinstance(item, dict): + return item + except json.JSONDecodeError: + continue + + for line in reversed(cleaned.splitlines()): + candidate = line.strip() + if not candidate.startswith("{"): + continue + try: + payload = json.loads(candidate) + if isinstance(payload, dict): + return payload + except json.JSONDecodeError: + continue + + raise json.JSONDecodeError("No JSON payload in das-cli output", cleaned, 0) + + +def extract_cli_status(payload: dict[str, Any]) -> str | None: + status = payload.get("status") + if status is None: + return None + return str(status).lower() + + +def extract_cli_message(payload: dict[str, Any]) -> str: + message = payload.get("message") + if message is None: + return "" + if isinstance(message, (list, tuple)): + return " ".join(str(item) for item in message if item) + return str(message).strip() + + +def _append_detail_part(parts: list[str], value: Any) -> None: + if value is None: + return + + if isinstance(value, list): + for item in value: + _append_detail_part(parts, item) + return + + if isinstance(value, dict): + nested_message = value.get("message") + if nested_message: + parts.append(str(nested_message)) + return + parts.append(json.dumps(value, ensure_ascii=False)) + return + + text = str(value).strip() + if text: + parts.append(text) + + +def extract_cli_error_detail(payload: dict[str, Any]) -> str: + parts: list[str] = [] + + _append_detail_part(parts, payload.get("errors")) + + error = payload.get("error") + if isinstance(error, dict): + _append_detail_part(parts, error.get("message") or error) + else: + _append_detail_part(parts, error) + + details = payload.get("details") + if isinstance(details, dict): + _append_detail_part(parts, details.get("errors")) + nested_error = details.get("error") + if isinstance(nested_error, dict): + _append_detail_part(parts, nested_error.get("message") or nested_error) + else: + _append_detail_part(parts, nested_error) + + deduped: list[str] = [] + for part in parts: + if part not in deduped: + deduped.append(part) + + return "\n".join(deduped) + + +def is_cli_success(payload: dict[str, Any]) -> bool: + status = extract_cli_status(payload) + if status is None: + return True + return status not in ERROR_STATUSES + + +def raise_cli_error_from_payload( + payload: dict[str, Any], + *, + default_message: str = DEFAULT_CLI_ERROR_MESSAGE, +) -> None: + message = extract_cli_message(payload) or default_message + detail = extract_cli_error_detail(payload) + raise DasCliCommandException(message=message, detail=detail) + + +def ensure_cli_success( + payload: dict[str, Any], + *, + default_message: str = DEFAULT_CLI_ERROR_MESSAGE, +) -> dict[str, Any]: + if is_cli_success(payload): + return payload + raise_cli_error_from_payload(payload, default_message=default_message) + return payload + + +def _format_manual_recovery_detail( + *, + exit_code: int | None, + cmd: list[str] | None = None, + stdout: str = "", + stderr: str = "", + raw_output: str = "", +) -> str: + lines: list[str] = [] + + if exit_code is not None: + lines.append( + f"das-cli exited with status {exit_code} without a formatted JSON response." + ) + else: + lines.append("das-cli returned output without a formatted JSON response.") + + if cmd: + lines.append(f"Command: {' '.join(shlex.quote(part) for part in cmd)}") + + lines.append( + "Check the server logs or run the command manually in a terminal for details." + ) + + cli_output = sanitize_cli_output_for_user(raw_output or stderr or stdout) + if cli_output: + lines.extend(["", "CLI output:", cli_output]) + elif exit_code is not None: + lines.extend(["", "CLI output: (empty)"]) + + return "\n".join(lines) + + +def raise_cli_command_failure( + *, + cmd: list[str], + exit_code: int, + stdout: str, + stderr: str, + default_message: str = DEFAULT_CLI_ERROR_MESSAGE, +) -> None: + for candidate in (stdout, stderr): + cleaned = clean_cli_output(candidate) + if not cleaned: + continue + + try: + payload = parse_das_cli_stdout(cleaned) + except json.JSONDecodeError: + continue + + if not is_cli_success(payload): + raise_cli_error_from_payload(payload, default_message=default_message) + + message = extract_cli_message(payload) or default_message + raise DasCliCommandException( + message=message, + detail=sanitize_cli_output_for_user(cleaned) or cleaned, + ) + + raw_output = clean_cli_output(stdout or stderr) + logger.error( + "das-cli failed without JSON: exit=%s cmd=%s stderr=%r stdout=%r", + exit_code, + cmd, + stderr[:500], + stdout[:500], + ) + raise DasCliCommandException( + message=default_message, + detail=_format_manual_recovery_detail( + exit_code=exit_code, + cmd=cmd, + stdout=stdout, + stderr=stderr, + raw_output=raw_output, + ), + ) + + +def raise_from_cli_output( + output: str, + *, + default_message: str = DEFAULT_CLI_ERROR_MESSAGE, + exit_code: int | None = None, + cmd: list[str] | None = None, + stdout: str = "", + stderr: str = "", +) -> None: + cleaned = clean_cli_output(output) + if not cleaned: + raise DasCliCommandException( + message=default_message, + detail=_format_manual_recovery_detail( + exit_code=exit_code, + cmd=cmd, + stdout=stdout, + stderr=stderr, + ), + ) + + try: + payload = parse_das_cli_stdout(cleaned) + except json.JSONDecodeError: + raise DasCliCommandException( + message=default_message, + detail=_format_manual_recovery_detail( + exit_code=exit_code, + cmd=cmd, + stdout=stdout or output, + stderr=stderr, + raw_output=cleaned, + ), + ) from None + + if not is_cli_success(payload): + raise_cli_error_from_payload(payload, default_message=default_message) + + message = extract_cli_message(payload) or default_message + raise DasCliCommandException( + message=message, + detail=sanitize_cli_output_for_user(cleaned) or cleaned, + ) + + +def parse_and_validate_cli_stdout( + stdout: str, + *, + default_message: str = DEFAULT_CLI_ERROR_MESSAGE, +) -> dict[str, Any]: + payload = parse_das_cli_stdout(stdout) + return ensure_cli_success(payload, default_message=default_message) + + +def run_das_cli_json_command( + cmd: list[str], + *, + default_message: str = DEFAULT_CLI_ERROR_MESSAGE, + timeout: float | None = DEFAULT_CLI_TIMEOUT_SECONDS, +) -> dict[str, Any]: + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + timeout=timeout, + ) + except subprocess.TimeoutExpired as error: + raise DasCliCommandException( + message=default_message, + detail="The das-cli command timed out.", + ) from error + except FileNotFoundError as error: + raise DasCliNotInstalledException("das-cli not found.") from error + + stdout = result.stdout or "" + stderr = result.stderr or "" + + if result.returncode != 0: + raise_cli_command_failure( + cmd=cmd, + exit_code=result.returncode, + stdout=stdout, + stderr=stderr, + default_message=default_message, + ) + + try: + return parse_and_validate_cli_stdout(stdout, default_message=default_message) + except json.JSONDecodeError: + logger.warning( + "das-cli exited 0 but returned no parseable JSON: cmd=%s stdout=%r", + cmd, + clean_cli_output(stdout)[:500], + ) + return {} diff --git a/das-dashboard/src/api/APIUtils.js b/das-dashboard/src/api/APIUtils.js index b8736adc..a4f6582b 100644 --- a/das-dashboard/src/api/APIUtils.js +++ b/das-dashboard/src/api/APIUtils.js @@ -1,28 +1,48 @@ -export function extractErrorDetails(err) { +export function extractErrorMessage(err, fallback = "An unexpected error occurred.") { if (!err) { - return "Unknown error."; + return fallback; } if (err.response) { const data = err.response.data; if (typeof data === "string") { - return data; + return fallback; } - if (data?.detail) { - return typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail); + if (data?.message) { + return data.message; + } + } + + if (err.message) { + return err.message; + } + + return fallback; +} + +export function extractErrorDetails(err) { + if (!err) { + return null; + } + + if (err.response) { + const data = err.response.data; + + if (typeof data === "string") { + return data; } if (data?.exceptionMessage) { - return `${data.message} Details: ${data.exceptionMessage}`; + return data.exceptionMessage; } - if (data?.message) { - return data.message; + if (data?.detail) { + return typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail); } - return JSON.stringify(data, null, 2); + return null; } if (err.request) { @@ -33,5 +53,13 @@ export function extractErrorDetails(err) { return err.message; } - return "Unexpected error."; + return null; +} + +export function extractApiError(err, fallbackMessage = "An unexpected error occurred.") { + return { + message: extractErrorMessage(err, fallbackMessage), + details: extractErrorDetails(err), + severity: "error", + }; } diff --git a/das-dashboard/src/components/common/ApiErrorNotice.jsx b/das-dashboard/src/components/common/ApiErrorNotice.jsx new file mode 100644 index 00000000..8b6759c4 --- /dev/null +++ b/das-dashboard/src/components/common/ApiErrorNotice.jsx @@ -0,0 +1,64 @@ +import { Box, Typography } from "@mui/material"; + +const stylesBySeverity = { + error: { + color: "#dc2626", + border: "1px solid rgba(220, 38, 38, 0.25)", + backgroundColor: "rgba(220, 38, 38, 0.06)" + }, + warning: { + color: "#b45309", + border: "1px solid rgba(180, 83, 9, 0.25)", + backgroundColor: "rgba(245, 158, 11, 0.08)" + } +}; + +export function ApiErrorNotice({ error, sx }) { + if (!error) { + return null; + } + + const normalized = typeof error === "string" + ? { message: error, details: null, severity: "error" } + : error; + + const message = normalized?.message == null ? "" : String(normalized.message); + const details = normalized?.details == null + ? null + : String(normalized.details); + const severity = normalized?.severity ?? "error"; + const palette = stylesBySeverity[severity] || stylesBySeverity.error; + + return ( + + + {message} + + {details ? ( + + {details} + + ) : null} + + ); +} diff --git a/das-dashboard/src/components/configuration_page/AtomDB/AdapterDB/AdapterDB.jsx b/das-dashboard/src/components/configuration_page/AtomDB/AdapterDB/AdapterDB.jsx index 94fcde05..7a60a139 100644 --- a/das-dashboard/src/components/configuration_page/AtomDB/AdapterDB/AdapterDB.jsx +++ b/das-dashboard/src/components/configuration_page/AtomDB/AdapterDB/AdapterDB.jsx @@ -15,6 +15,7 @@ import { useRef, useState } from "react" import { useConfig } from "../../../global_providers/ConfigurationProvider" import { useToast } from "../../../global_providers/ToastProvider" import { saveContextMapping } from "../../../../api/ConfigAPI" +import { extractApiError } from "../../../../api/APIUtils" import { MANAGED_CONTEXT_MAPPING_PATH } from "./adapterConstants" import { initAdapterBackend, parsePortValue } from "../../configFormUtils" import { ConfigForm } from "../../ConfigForm" @@ -104,7 +105,8 @@ export function AdapterDBOptions() { showToast({ message: "Context mapping saved", severity: "success" }) } catch (error) { console.error(error) - showToast({ message: "Failed to save context mapping", severity: "error" }) + const { message, details, severity } = extractApiError(error, "Failed to save context mapping") + showToast({ message, severity, details }) } } diff --git a/das-dashboard/src/components/dashboard/MainContent/servicestable/ServicesTable.jsx b/das-dashboard/src/components/dashboard/MainContent/servicestable/ServicesTable.jsx index fb44b02e..e170b89e 100644 --- a/das-dashboard/src/components/dashboard/MainContent/servicestable/ServicesTable.jsx +++ b/das-dashboard/src/components/dashboard/MainContent/servicestable/ServicesTable.jsx @@ -2,7 +2,7 @@ import { Table, TableHead, TableRow, TableBody } from "@mui/material"; import { useDashboardContext } from "../../../global_providers/DashboardContextProvider"; import { useServerTabMetricsContext } from "../../../global_providers/ServerTabMetricsProvider"; import { stopService, restartService, startService } from "../../../../api/ServicesAPI"; -import { extractErrorDetails } from "../../../../api/APIUtils"; +import { extractApiError } from "../../../../api/APIUtils"; import { AgentRow } from "./AgentRow"; import { EmptyContent } from "./EmptyContent"; import { TableContainer, HeaderCell } from "./servicestable.styled"; @@ -51,12 +51,11 @@ export function AgentTable({ machine }) { } } catch (error) { console.error("Error while executing action:", error); - const serverMessage = error?.response?.data?.message; - showToast({ - message: serverMessage || `Failed to ${actionType.toLowerCase()} service ${serviceKey}.`, - severity: "error", - details: extractErrorDetails(error), - }); + const { message, details, severity } = extractApiError( + error, + `Failed to ${actionType.toLowerCase()} service ${serviceKey}.` + ); + showToast({ message, severity, details }); } } diff --git a/das-dashboard/src/components/dashboard/MainContent/sidebar/ArchitectureActionControl.jsx b/das-dashboard/src/components/dashboard/MainContent/sidebar/ArchitectureActionControl.jsx index 1ba6e51c..ee56f5ed 100644 --- a/das-dashboard/src/components/dashboard/MainContent/sidebar/ArchitectureActionControl.jsx +++ b/das-dashboard/src/components/dashboard/MainContent/sidebar/ArchitectureActionControl.jsx @@ -18,7 +18,7 @@ import { useToast } from "../../../global_providers/ToastProvider"; import { useDialog } from "../../../global_providers/DialogProvider"; import { useDashboardContext } from "../../../global_providers/DashboardContextProvider"; import { startArchitecture, stopArchitecture } from "../../../../api/ServicesAPI"; -import { extractErrorDetails } from "../../../../api/APIUtils"; +import { extractApiError } from "../../../../api/APIUtils"; const CORE_TOOLTIP = "'Core' refers to necessary services to start/connect to other agents; disabling them can cause the architecture to be unusable or prone to failure."; @@ -126,12 +126,8 @@ export function ArchitectureActionControl({ showToast({ message: successMessage, severity: "success" }); } catch (err) { console.error(errorMessage, err); - const serverMessage = err?.response?.data?.message; - showToast({ - message: serverMessage || errorMessage, - severity: "error", - details: extractErrorDetails(err), - }); + const { message, details, severity } = extractApiError(err, errorMessage); + showToast({ message, severity, details }); } finally { setBusy(null); } diff --git a/das-dashboard/src/components/dashboard/MainContent/sidebar/AtomDBActionControl.jsx b/das-dashboard/src/components/dashboard/MainContent/sidebar/AtomDBActionControl.jsx index 8e09d0cd..dfe3db6d 100644 --- a/das-dashboard/src/components/dashboard/MainContent/sidebar/AtomDBActionControl.jsx +++ b/das-dashboard/src/components/dashboard/MainContent/sidebar/AtomDBActionControl.jsx @@ -8,7 +8,7 @@ import { useToast } from "../../../global_providers/ToastProvider"; import { useDialog } from "../../../global_providers/DialogProvider"; import { startDatabases, stopDatabases } from "../../../../api/ServicesAPI"; import { getConfigDefaults } from "../../../../api/ConfigAPI"; -import { extractErrorDetails } from "../../../../api/APIUtils"; +import { extractApiError } from "../../../../api/APIUtils"; const LOADING_KEYS = ["start-database", "stop-database"]; @@ -53,7 +53,8 @@ export function AtomDBActionControl({ showToast({ message: successMessage, severity: "success" }); } catch (err) { console.error(errorMessage, err); - showToast({ message: errorMessage, severity: "error", details: extractErrorDetails(err) }); + const { message, details, severity } = extractApiError(err, errorMessage); + showToast({ message, severity, details }); } finally { setBusy(null); } diff --git a/das-dashboard/src/components/dashboard/MainContent/sidebar/MettaLoadActionControl.jsx b/das-dashboard/src/components/dashboard/MainContent/sidebar/MettaLoadActionControl.jsx index 70c83335..e557dc83 100644 --- a/das-dashboard/src/components/dashboard/MainContent/sidebar/MettaLoadActionControl.jsx +++ b/das-dashboard/src/components/dashboard/MainContent/sidebar/MettaLoadActionControl.jsx @@ -7,7 +7,7 @@ import { useDashboardContext } from "../../../global_providers/DashboardContextP import { useToast } from "../../../global_providers/ToastProvider"; import { useDialog } from "../../../global_providers/DialogProvider"; import { uploadMettaFile, loadMettaFile } from "../../../../api/AtomDBAPI"; -import { extractErrorDetails } from "../../../../api/APIUtils"; +import { extractApiError } from "../../../../api/APIUtils"; const LOADING_KEYS = ["load-metta", "overwrite-metta", "load-existing-metta", "upload-metta"]; @@ -38,7 +38,8 @@ export function MettaLoadActionControl({ showToast({ message: successMessage, severity: "success" }); } catch (err) { console.error(errorMessage, err); - showToast({ message: errorMessage, severity: "error", details: extractErrorDetails(err) }); + const { message, details, severity } = extractApiError(err, errorMessage); + showToast({ message, severity, details }); } finally { setBusy(null); } @@ -89,7 +90,8 @@ export function MettaLoadActionControl({ } }); } else { - showToast({ message: "Failed to upload MeTTa database.", severity: "error", details: extractErrorDetails(err) }); + const { message, details, severity } = extractApiError(err, "Failed to upload MeTTa database."); + showToast({ message, severity, details }); } } finally { setBusy(null); diff --git a/das-dashboard/src/components/dashboard/MainContent/sidebar/SideBar.jsx b/das-dashboard/src/components/dashboard/MainContent/sidebar/SideBar.jsx index 9f2cbf4a..37d4bf73 100644 --- a/das-dashboard/src/components/dashboard/MainContent/sidebar/SideBar.jsx +++ b/das-dashboard/src/components/dashboard/MainContent/sidebar/SideBar.jsx @@ -15,7 +15,10 @@ import { import { useDashboardContext } from "../../../global_providers/DashboardContextProvider"; import { useServerTabMetricsContext } from "../../../global_providers/ServerTabMetricsProvider"; -import { fetchInfraStatusForAllHosts } from "../../../../utils/infraStatus"; +import { + fetchInfraStatusForAllHosts, + pollInfraStatusForAllHosts, +} from "../../../../utils/infraStatus"; import { ArchitectureActionControl } from "./ArchitectureActionControl"; import { AtomDBActionControl } from "./AtomDBActionControl"; @@ -35,9 +38,11 @@ export function SideBar() { const { setCurrentContext, currentMachine, currentContext, machines } = useDashboardContext(); const { hostStreamSwitching } = useServerTabMetricsContext(); - const loadInfraStatus = useCallback(async () => { + const loadInfraStatus = useCallback(async ({ poll = false } = {}) => { const serverIps = machines.map((machine) => machine.serverIp).filter(Boolean); - const statusByHost = await fetchInfraStatusForAllHosts(serverIps); + const statusByHost = poll + ? await pollInfraStatusForAllHosts(serverIps, { attempts: 5, delayMs: 2000 }) + : await fetchInfraStatusForAllHosts(serverIps); setAtomDbOnline( Object.values(statusByHost).some((status) => status.atomDbOnline) @@ -108,7 +113,7 @@ export function SideBar() { isServerOffline={isServerOffline} disabled={isAnyActionLoading && !busyActions.atomdb} onBusyChange={(busy) => setActionBusy("atomdb", busy)} - onActionComplete={loadInfraStatus} + onActionComplete={() => loadInfraStatus({ poll: true })} /> setActionBusy("architecture", busy)} - onActionComplete={loadInfraStatus} + onActionComplete={() => loadInfraStatus({ poll: true })} /> { @@ -152,9 +153,7 @@ export default function QueryAllAnswersModal({ ) : error ? ( - - {error} - + ) : items.length > 0 ? ( {items.map((answer) => ( diff --git a/das-dashboard/src/hooks/useQueryExecution.js b/das-dashboard/src/hooks/useQueryExecution.js index c14c0e57..2963bc19 100644 --- a/das-dashboard/src/hooks/useQueryExecution.js +++ b/das-dashboard/src/hooks/useQueryExecution.js @@ -4,7 +4,7 @@ import { setQueryParameters, startQueryExecution } from "../api/QueryAPI"; -import { extractErrorDetails } from "../api/APIUtils"; +import { extractApiError } from "../api/APIUtils"; import { createQueryExecutionStream } from "../api/QueryStreamService"; import { buildFrequencyHistogram, buildStiChart } from "../utils/queryCharts"; import { formatQueryAnswer } from "../utils/formatQueryAnswer"; @@ -104,7 +104,11 @@ export function useQueryExecution(parameters) { setIsRunning(false); if (event?.message) { - setStreamError(event.message); + setStreamError({ + message: event.message, + details: event.details ?? null, + severity: "error" + }); } if (typeof event?.received_count === "number" && !isCountOnlyRef.current) { @@ -144,8 +148,13 @@ export function useQueryExecution(parameters) { streamRef.current = createQueryExecutionStream(nextExecutionId, { onEvent: handleStreamEvent, - onError: (error) => { - setStreamError(error.message); + onError: () => { + setStreamError({ + message: "Lost connection to the query stream.", + details: + "Check that the Command Router is still running in your architecture.", + severity: "error" + }); setIsRunning(false); }, onClose: () => { @@ -186,7 +195,7 @@ export function useQueryExecution(parameters) { } catch (error) { startedAtRef.current = null; setIsRunning(false); - setStreamError(extractErrorDetails(error)); + setStreamError(extractApiError(error, "Failed to start query execution.")); } }, [connectStream, parameters, resetSession] @@ -203,7 +212,7 @@ export function useQueryExecution(parameters) { await cancelQueryExecution(executionIdRef.current); finishExecution(null); } catch (error) { - setStreamError(extractErrorDetails(error)); + setStreamError(extractApiError(error, "Failed to stop query execution.")); } }, [finishExecution]); diff --git a/das-dashboard/src/pages/query/QueryPage.jsx b/das-dashboard/src/pages/query/QueryPage.jsx index de590f10..ee123b7b 100644 --- a/das-dashboard/src/pages/query/QueryPage.jsx +++ b/das-dashboard/src/pages/query/QueryPage.jsx @@ -11,6 +11,7 @@ import { QueryExecutionProvider, useQueryExecutionContext } from "../../components/global_providers/QueryExecutionProvider"; +import { ApiErrorNotice } from "../../components/common/ApiErrorNotice"; import { useQueryParameters } from "../../hooks/useQueryParameters"; import { PageContainer, @@ -36,8 +37,7 @@ import { SideBarSubtitle, SideBarTitle, SideBarTitleHeader, - StopButton, - QueryStreamError + StopButton } from "./querypage.styled"; function QueryPageContent() { @@ -155,7 +155,7 @@ function QueryPageContent() { /> {streamError ? ( - {streamError} + ) : null} diff --git a/das-dashboard/src/pages/setup_das/SetupDas.jsx b/das-dashboard/src/pages/setup_das/SetupDas.jsx index 47cd1e34..7d62b8a0 100644 --- a/das-dashboard/src/pages/setup_das/SetupDas.jsx +++ b/das-dashboard/src/pages/setup_das/SetupDas.jsx @@ -24,7 +24,7 @@ import { useState, useRef } from "react" import { loadConfig, saveConfig } from "../../api/ConfigAPI" import { getInitialState } from "../../api/DashboardAPI" -import { extractErrorDetails } from "../../api/APIUtils" +import { extractApiError } from "../../api/APIUtils" import { useToast } from "../../components/global_providers/ToastProvider" import AtomDBForm from "../../components/configuration_page/AtomDB/AtomDB" @@ -103,10 +103,14 @@ export default function SetupDasPage() { await refreshDashboardState() } catch (refreshError) { console.error("Failed to refresh dashboard state after save:", refreshError) + const { message, details } = extractApiError( + refreshError, + "Configuration saved, but dashboard state could not be refreshed." + ) showToast({ - message: "Configuration saved, but dashboard state could not be refreshed.", + message, severity: "warning", - details: extractErrorDetails(refreshError) + details }) } @@ -122,11 +126,8 @@ export default function SetupDasPage() { } catch (error) { console.error(error) - showToast({ - message: "Failed to save configuration", - severity: "error", - details: extractErrorDetails(error) - }) + const { message, details, severity } = extractApiError(error, "Failed to save configuration") + showToast({ message, severity, details }) } // Sets back to normal after action is completed. @@ -179,21 +180,22 @@ export default function SetupDasPage() { await refreshDashboardState() } catch (refreshError) { console.error("Failed to refresh dashboard state after load:", refreshError) + const { message, details } = extractApiError( + refreshError, + "Configuration loaded, but dashboard state could not be refreshed." + ) showToast({ - message: "Configuration loaded, but dashboard state could not be refreshed.", + message, severity: "warning", - details: extractErrorDetails(refreshError) + details }) } showToast({ message: "Configuration loaded successfully", severity: "success" }) } catch (error) { console.error(error) - showToast({ - message: "Failed to load configuration", - severity: "error", - details: extractErrorDetails(error) - }) + const { message, details, severity } = extractApiError(error, "Failed to load configuration") + showToast({ message, severity, details }) } finally { setDisableActions(false) document.body.style.cursor = "default" @@ -216,11 +218,8 @@ export default function SetupDasPage() { await saveFile(savedConfigContent) } catch (error) { console.error(error) - showToast({ - message: "Failed to save local copy", - severity: "error", - details: extractErrorDetails(error) - }) + const { message, details, severity } = extractApiError(error, "Failed to save local copy") + showToast({ message, severity, details }) } finally { closeSaveCopyDialog() } @@ -413,11 +412,8 @@ export default function SetupDasPage() { showToast({ message: "Configuration reset", severity: "success" }) } catch (error) { console.error(error) - showToast({ - message: "Failed to reset configuration", - severity: "error", - details: extractErrorDetails(error) - }) + const { message, details, severity } = extractApiError(error, "Failed to reset configuration") + showToast({ message, severity, details }) } }} > diff --git a/das-dashboard/src/utils/infraStatus.js b/das-dashboard/src/utils/infraStatus.js index aec14b16..0d5237ca 100644 --- a/das-dashboard/src/utils/infraStatus.js +++ b/das-dashboard/src/utils/infraStatus.js @@ -15,17 +15,32 @@ const ARCHITECTURE_COMMAND_LABELS = new Set([ "inference-agent", ]); +const ATOMDB_NAME_MARKERS = [ + "mongodb", + "redis", + "morkdb", + "database-adapter", + "das-database-adapter", +]; + +const ATOMDB_COMMAND_LABELS = new Set(["db", "database-adapter", "database"]); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + function isRunning(status) { return String(status ?? "").toLowerCase() === "running"; } function isAtomDbEntry(entry) { - if (entry?.service_command_label === "db") { + const label = String(entry?.service_command_label ?? "").toLowerCase(); + if (ATOMDB_COMMAND_LABELS.has(label)) { return true; } const name = String(entry?.container_name ?? "").toLowerCase(); - return ["mongodb", "redis", "morkdb"].some((marker) => name.includes(marker)); + return ATOMDB_NAME_MARKERS.some((marker) => name.includes(marker)); } function isArchitectureEntry(entry) { @@ -98,3 +113,19 @@ export async function fetchInfraStatusForAllHosts(hosts = []) { return Object.fromEntries(entries); } + +export async function pollInfraStatusForAllHosts( + hosts = [], + { attempts = 5, delayMs = 2000 } = {} +) { + let latest = {}; + + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (attempt > 0) { + await sleep(delayMs); + } + latest = await fetchInfraStatusForAllHosts(hosts); + } + + return latest; +} diff --git a/das-dashboard/src/utils/serviceRows.js b/das-dashboard/src/utils/serviceRows.js index c644355f..88c20085 100644 --- a/das-dashboard/src/utils/serviceRows.js +++ b/das-dashboard/src/utils/serviceRows.js @@ -1,19 +1,40 @@ -const ATOMDB_MARKERS = { db: "mongodb", redis: "redis", morkdb: "morkdb", "adapter-backend": "adapter" }; +const ATOMDB_MARKERS = { + db: "mongodb", + redis: "redis", + morkdb: "morkdb", + adapterdb: "database-adapter", + "adapter-backend": "adapter", +}; + +const ATOMDB_RUNTIME_LABELS = new Set(["db", "database-adapter", "database"]); function matchesRuntime(serviceKey, runtime) { - if (runtime?.service_command_label === serviceKey) return true; + const label = String(runtime?.service_command_label ?? "").toLowerCase(); + const normalizedKey = String(serviceKey).toLowerCase(); + + if (label === normalizedKey) { + return true; + } const name = String(runtime?.container_name ?? "").toLowerCase(); - if (!name) return false; + if (!name) { + return false; + } - const marker = ATOMDB_MARKERS[serviceKey]; + const marker = ATOMDB_MARKERS[normalizedKey]; if (marker) { - if (!name.includes(marker)) return false; - const label = runtime?.service_command_label; - return !label || label === "db" || label === serviceKey; + if (!name.includes(marker)) { + return false; + } + + if (!label) { + return true; + } + + return ATOMDB_RUNTIME_LABELS.has(label) || label === normalizedKey; } - return name.includes(String(serviceKey).toLowerCase()); + return name.includes(normalizedKey); } export function patchServicesWithRuntime(baseServices = [], runtimeServices = []) {