From 8f313e79bb855ef7f41de8caab285c0cf099f59b Mon Sep 17 00:00:00 2001 From: Naci Dai Date: Sun, 21 Sep 2025 18:52:45 +0000 Subject: [PATCH 1/5] basic symphony provider --- agent/config.py | 70 ++- agent/mqtt.py | 60 +- agent/mqtt_manager.py | 21 +- agent/muto_agent.py | 43 +- agent/symphony/README.md | 56 ++ agent/symphony/__init__.py | 68 +++ agent/symphony/sdk/README.md | 68 +++ agent/symphony/sdk/__init__.py | 84 +++ agent/symphony/sdk/symphony_api.py | 682 +++++++++++++++++++++++ agent/symphony/sdk/symphony_sdk.py | 738 +++++++++++++++++++++++++ agent/symphony/sdk/symphony_summary.py | 350 ++++++++++++ agent/symphony/sdk/symphony_types.py | 346 ++++++++++++ agent/symphony/symphony_broker.py | 260 +++++++++ agent/symphony/symphony_provider.py | 700 +++++++++++++++++++++++ config/define-instance.sh | 17 + config/define-solution.sh | 75 +++ config/delete_muto_provider.sh | 8 + config/instance.json | 14 + config/solution.json | 22 + config/talker-listener-solution.json | 31 ++ config/talker-listener.json | 17 + config/target.json | 34 ++ setup.py | 3 +- test/test_symphony_api.py | 188 +++++++ test/test_symphony_sdk.py | 511 +++++++++++++++++ test/test_symphony_summary.py | 296 ++++++++++ test/test_symphony_types.py | 197 +++++++ 27 files changed, 4928 insertions(+), 31 deletions(-) create mode 100644 agent/symphony/README.md create mode 100644 agent/symphony/__init__.py create mode 100644 agent/symphony/sdk/README.md create mode 100644 agent/symphony/sdk/__init__.py create mode 100644 agent/symphony/sdk/symphony_api.py create mode 100644 agent/symphony/sdk/symphony_sdk.py create mode 100644 agent/symphony/sdk/symphony_summary.py create mode 100644 agent/symphony/sdk/symphony_types.py create mode 100644 agent/symphony/symphony_broker.py create mode 100644 agent/symphony/symphony_provider.py create mode 100755 config/define-instance.sh create mode 100755 config/define-solution.sh create mode 100755 config/delete_muto_provider.sh create mode 100644 config/instance.json create mode 100644 config/solution.json create mode 100644 config/talker-listener-solution.json create mode 100644 config/talker-listener.json create mode 100644 config/target.json create mode 100644 test/test_symphony_api.py create mode 100644 test/test_symphony_sdk.py create mode 100644 test/test_symphony_summary.py create mode 100644 test/test_symphony_types.py diff --git a/agent/config.py b/agent/config.py index 0fffb81..293ec8e 100644 --- a/agent/config.py +++ b/agent/config.py @@ -42,7 +42,21 @@ class MQTTConfig: prefix: str = "muto" name: str = "" - +@dataclass +class SymphonyConfig: + """Configuration for Symphony connection.""" + mqtt: MQTTConfig = field(default_factory=MQTTConfig) + target: str = "muto-target" + enabled: bool = False + topic_prefix: str = "symphony" + api_url: str = "http://localhost:8082/v1alpha2/", + provider_name: str = "providers.target.mqtt", + broker_address: str = "tcp://mosquitto:1883", + client_id: str = "symphony", + request_topic: str = "coa-request", + response_topic: str = "coa-response", + timeout_seconds: int = 30, + auto_register: bool = False @dataclass class TopicConfig: """Configuration for ROS topics.""" @@ -60,6 +74,7 @@ class AgentConfig: """Main configuration for the Muto Agent.""" mqtt: MQTTConfig = field(default_factory=MQTTConfig) topics: TopicConfig = field(default_factory=TopicConfig) + symphony: SymphonyConfig = field(default_factory=SymphonyConfig) class ConfigurationManager: @@ -107,6 +122,33 @@ def load_config(self) -> AgentConfig: name=self._get_parameter("name", "") ) + sym_mqtt_config = MQTTConfig( + host=self._get_parameter("symphony_host", "sandbox.composiv.ai"), + port=self._get_parameter("symphony_port", 1883), + keep_alive=self._get_parameter("symphony_keep_alive", 60), + user=self._get_parameter("symphony_user", ""), + password=self._get_parameter("symphony_password", ""), + namespace=self._get_parameter("symphony_namespace", ""), + prefix=self._get_parameter("symphony_prefix", "muto"), + name=self._get_parameter("symphony_name", "") + ) + + symphony_config = SymphonyConfig( + mqtt=sym_mqtt_config, + target=self._get_parameter("symphony_target_name", "muto-target"), + enabled=self._get_parameter("symphony_enabled", False), + + topic_prefix=self._get_parameter("symphony_topic_prefix", "symphony"), + api_url=self._get_parameter("symphony_api_url", "http://localhost:8082/v1alpha2/"), + provider_name=self._get_parameter("symphony_provider_name", "providers.target.mqtt"), + broker_address=self._get_parameter("symphony_broker_address", "tcp://mosquitto:1883"), + client_id=self._get_parameter("symphony_client_id", "symphony"), + request_topic=self._get_parameter("symphony_request_topic", "coa-request"), + response_topic=self._get_parameter("symphony_response_topic", "coa-response"), + timeout_seconds=self._get_parameter("symphony_timeout_seconds", 30), + auto_register=self._get_parameter("symphony_auto_register", False), + ) + # Load topic configuration topic_config = TopicConfig( stack_topic=self._get_parameter("stack_topic", "stack"), @@ -118,7 +160,7 @@ def load_config(self) -> AgentConfig: thing_messages_topic=self._get_parameter("thing_messages_topic", "thing_messages") ) - self._config = AgentConfig(mqtt=mqtt_config, topics=topic_config) + self._config = AgentConfig(mqtt=mqtt_config, topics=topic_config, symphony=symphony_config) self._validate_config() self._node.get_logger().info("Configuration loaded successfully") @@ -154,11 +196,33 @@ def _declare_parameters(self) -> None: ("name", ""), ("stack_topic", "stack"), ("twin_topic", "twin"), + ("agent_to_gateway_topic", "agent_to_gateway"), ("gateway_to_agent_topic", "gateway_to_agent"), ("agent_to_commands_topic", "agent_to_command"), ("commands_to_agent_topic", "command_to_agent"), - ("thing_messages_topic", "thing_messages") + ("thing_messages_topic", "thing_messages"), + + ("symphony_enabled", False), + ("symphony_host", "sandbox.composiv.ai"), + ("symphony_port", 1883), + ("symphony_keep_alive", 60), + ("symphony_namespace", ""), + ("symphony_prefix", "muto"), + ('symphony_target_name', 'muto-device-001'), + ('symphony_topic_prefix', 'symphony'), + ('symphony_enable', False), + ('symphony_api_url', 'http://localhost:8082/v1alpha2/'), + ('symphony_user', 'admin'), + ('symphony_password', ''), + ('symphony_name', 'muto-device-001'), + ('symphony_provider_name', 'providers.target.mqtt'), + ('symphony_broker_address', 'tcp://mosquitto:1883'), + ('symphony_client_id', 'symphony'), + ('symphony_request_topic', 'coa-request'), + ('symphony_response_topic', 'coa-response'), + ('symphony_timeout_seconds', '30'), + ('symphony_auto_register', False), ] for param_name, default_value in parameters: diff --git a/agent/mqtt.py b/agent/mqtt.py index 068a439..72f5a1b 100644 --- a/agent/mqtt.py +++ b/agent/mqtt.py @@ -20,6 +20,8 @@ # Standard library imports import json +import signal +import threading from typing import Optional # Third-party imports @@ -311,26 +313,56 @@ def is_mqtt_connected(self) -> bool: def main(): - """Main entry point for the MQTT Gateway.""" - rclpy.init() + """Main entry point for the Muto MQTT.""" + provider = None + shutdown_requested = threading.Event() + + def signal_handler(signum, frame): + """Handle shutdown signals gracefully.""" + print(f"Received signal {signum}, initiating graceful shutdown...") + shutdown_requested.set() + if provider is not None: + provider._shutdown_event.set() + + # Register signal handlers + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) try: - gateway = MQTT() - gateway.initialize() + rclpy.init() + provider = MQTT() + provider.initialize() + + provider.get_logger().info("Muto MQTT started successfully") - gateway.get_logger().info("MQTT Gateway started successfully") - rclpy.spin(gateway) + # Custom spin loop to handle shutdown gracefully + while rclpy.ok() and not shutdown_requested.is_set(): + try: + rclpy.spin_once(provider, timeout_sec=1.0) + except KeyboardInterrupt: + break + except KeyboardInterrupt: + print("Muto MQTT interrupted by user") except Exception as e: - print(f"Failed to start MQTT Gateway: {e}") + print(f"Failed to start Muto MQTT: {e}") finally: + # Cleanup provider if it was created + if provider is not None: + try: + print("Cleaning up Muto MQTT...") + provider.cleanup() + except Exception as e: + print(f"Error during provider cleanup: {e}") + + # Only shutdown ROS2 if it's still initialized and we haven't already shut it down try: - gateway.cleanup() - except: - pass - rclpy.shutdown() - + if rclpy.ok(): + print("Shutting down ROS2...") + rclpy.shutdown() + except Exception as e: + print(f"Error during ROS2 shutdown (this may be normal): {e}") -if __name__ == "__main__": - main() +if __name__ == '__main__': + exit(main()) \ No newline at end of file diff --git a/agent/mqtt_manager.py b/agent/mqtt_manager.py index a680bc6..7d242c5 100644 --- a/agent/mqtt_manager.py +++ b/agent/mqtt_manager.py @@ -42,7 +42,7 @@ class MQTTConnectionManager(ConnectionManager): automatic reconnection, proper error handling, and connection monitoring. """ - def __init__(self, node: BaseNode, config: MQTTConfig, message_handler: Callable[[MQTTMessage], None], logger: Optional[Any] = None): + def __init__(self, node: BaseNode, config: MQTTConfig, message_handler: Callable[[MQTTMessage], None], on_connect_handler: Optional[Callable] = None, logger: Optional[Any] = None): """ Initialize the MQTT connection manager. @@ -53,6 +53,7 @@ def __init__(self, node: BaseNode, config: MQTTConfig, message_handler: Callable """ self._config = config self._message_handler = message_handler + self._on_connect_handler = on_connect_handler self._client: Optional[Client] = None self._connected = False self._node = node @@ -209,14 +210,20 @@ def _on_connect(self, client, userdata, flags, reason_code, properties) -> None: reason_code: Connection result code. properties: MQTT v5 properties. """ + + if reason_code == 0: self._connected = True - - # Subscribe to twin topic - twin_topic = f"{self._config.prefix}/{self._config.namespace}:{self._config.name}" - self.subscribe(twin_topic) - - self.get_logger().info(f"MQTT connected and subscribed to {twin_topic}") + + # If there is a self.on_connect_handler use it otherwise default + if self._on_connect_handler is not None: + self._on_connect_handler(client, userdata, flags, reason_code, properties) + else: + # default behavior + # Subscribe to twin topic + twin_topic = f"{self._config.prefix}/{self._config.namespace}:{self._config.name}" + self.subscribe(twin_topic) + self.get_logger().info(f"MQTT connected and subscribed to {twin_topic}") else: self._connected = False self.get_logger().error(f"MQTT connection failed with reason code: {reason_code}") diff --git a/agent/muto_agent.py b/agent/muto_agent.py index dfacde2..e2a328a 100755 --- a/agent/muto_agent.py +++ b/agent/muto_agent.py @@ -27,6 +27,8 @@ """ # Standard library imports +import signal +import threading from typing import Optional, Tuple # Third-party imports @@ -253,27 +255,56 @@ def is_ready(self) -> bool: def main(): """Main entry point for the Muto Agent.""" - rclpy.init() + agent = None + shutdown_requested = threading.Event() + + def signal_handler(signum, frame): + """Handle shutdown signals gracefully.""" + print(f"Received signal {signum}, initiating graceful shutdown...") + shutdown_requested.set() + + # Register signal handlers + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) try: + rclpy.init() agent = MutoAgent() agent.initialize() if agent.is_ready(): agent.get_logger().info("Muto Agent started successfully") - rclpy.spin(agent) + + # Custom spin loop to handle shutdown gracefully + while rclpy.ok() and not shutdown_requested.is_set(): + try: + rclpy.spin_once(agent, timeout_sec=1.0) + except KeyboardInterrupt: + break else: agent.get_logger().error("Muto Agent failed to initialize properly") + except KeyboardInterrupt: + print("Muto Agent interrupted by user") except Exception as e: print(f"Failed to start Muto Agent: {e}") finally: + # Cleanup agent if it was created + if agent is not None: + try: + print("Cleaning up Muto Agent...") + agent.cleanup() + except Exception as e: + print(f"Error during agent cleanup: {e}") + + # Only shutdown ROS2 if it's still initialized and we haven't already shut it down try: - agent.cleanup() - except: - pass - rclpy.shutdown() + if rclpy.ok(): + print("Shutting down ROS2...") + rclpy.shutdown() + except Exception as e: + print(f"Error during ROS2 shutdown (this may be normal): {e}") if __name__ == "__main__": diff --git a/agent/symphony/README.md b/agent/symphony/README.md new file mode 100644 index 0000000..d5ba714 --- /dev/null +++ b/agent/symphony/README.md @@ -0,0 +1,56 @@ +# SDK Package + +The `agent.symphony.sdk` package contains an SDK for Symphony data structures and utilities: + +- **symphony_api.py**: REST API client and authentication +- **symphony_sdk.py**: COA protocol, data structures, serialization +- **symphony_summary.py**: Deployment summary and result models +- **symphony_types.py**: State enums and constants + +## Package Structure + +``` +src/agent/agent/symphony/ +├── __init__.py # Package exports and convenience imports +├── symphony_broker.py # MQTT broker integration +├── symphony_provider.py # Main Symphony provider implementation +└── sdk/ # Core SDK components + ├── __init__.py # SDK exports and convenience imports + ├── symphony_api.py # REST API client for Symphony + ├── symphony_sdk.py # COA data structures and SDK + ├── symphony_summary.py # Summary models and result handling + └── symphony_types.py # State enums and constants +``` + +## Import +```python +# Convenient imports from main symphony package +from agent.symphony import COARequest, COAResponse, State + +# SDK-specific imports +from agent.symphony.sdk import SummaryResult, SymphonyAPIClient + +# Provider/broker imports +from agent.symphony.symphony_provider import MutoSymphonyProvider + +# Direct module access +from agent.symphony.sdk.symphony_types import State as SymphonyState +``` + + +### SDK Usage Examples + +```python +# Import everything from SDK +from agent.symphony.sdk import * + +# Create COA request/response +request = COARequest(method='GET', route='/targets') +response = COAResponse.success({'targets': []}) + +# Use summary models +summary = SummarySpec(target_count=1, success_count=1) + +# API client usage +client = SymphonyAPIClient(base_url='http://localhost:8082/v1alpha2/') +``` \ No newline at end of file diff --git a/agent/symphony/__init__.py b/agent/symphony/__init__.py new file mode 100644 index 0000000..977d713 --- /dev/null +++ b/agent/symphony/__init__.py @@ -0,0 +1,68 @@ +""" +Symphony Integration Package for Muto Agent. + +This package contains all Symphony-related components including: +- API client +- MQTT broker integration +- Provider implementation +- SDK data structures +- Summary models +- Type definitions +""" + +# Import commonly used components for convenience +from .sdk.symphony_sdk import ( + COARequest, + COAResponse, + ComponentSpec, + DeploymentSpec, + SolutionSpec, + SymphonyProvider, + TargetSpec, + deserialize_coa_request, + deserialize_coa_response, + serialize_coa_request, + serialize_coa_response, +) +from .sdk.symphony_summary import ( + ComponentResultSpec, + SummaryResult, + SummarySpec, + SummaryState, + TargetResultSpec, +) +from .sdk.symphony_types import ( + COAConstants, + State, + Terminable, + get_http_status, +) + +# Export main classes for external use +__all__ = [ + # SDK components + 'COARequest', + 'COAResponse', + 'ComponentSpec', + 'DeploymentSpec', + 'SolutionSpec', + 'SymphonyProvider', + 'TargetSpec', + 'deserialize_coa_request', + 'deserialize_coa_response', + 'serialize_coa_request', + 'serialize_coa_response', + + # Types + 'COAConstants', + 'State', + 'Terminable', + 'get_http_status', + + # Summary models + 'ComponentResultSpec', + 'SummaryResult', + 'SummarySpec', + 'SummaryState', + 'TargetResultSpec', +] \ No newline at end of file diff --git a/agent/symphony/sdk/README.md b/agent/symphony/sdk/README.md new file mode 100644 index 0000000..780e8f9 --- /dev/null +++ b/agent/symphony/sdk/README.md @@ -0,0 +1,68 @@ +# Symphony SDK + +Core SDK components for Symphony integration. + +## Components + +### symphony_api.py +- **SymphonyAPIClient**: REST API client for Symphony operations +- **SymphonyAPIError**: Custom exception for API errors +- Authentication and target management + +### symphony_sdk.py +- **COA Protocol**: COARequest, COAResponse classes +- **Data Models**: ComponentSpec, TargetSpec, DeploymentSpec, etc. +- **Serialization**: JSON serialization/deserialization utilities +- **SymphonyProvider**: Abstract base class for providers + +### symphony_summary.py +- **SummaryResult**: Complete deployment summary container +- **SummarySpec**: Deployment statistics and target results +- **ComponentResultSpec**: Individual component operation results +- **TargetResultSpec**: Target-level operation results +- **SummaryState**: Enumeration for summary states + +### symphony_types.py +- **State**: Comprehensive state enum matching Symphony Go implementation +- **COAConstants**: Protocol constants and configuration keys +- **Terminable**: Interface for graceful shutdown +- **Utility functions**: HTTP status code mapping + +## Quick Start + +```python +from agent.symphony.sdk import * + +# Create a COA request +request = COARequest( + method='POST', + route='/targets/deploy', + content_type='application/json' +) +request.set_body({'components': [{'name': 'app', 'type': 'container'}]}) + +# Create a response +response = COAResponse.success({'deployed': True}) + +# Use summary models +summary = SummarySpec(target_count=1, success_count=1) +result = SummaryResult(summary=summary, state=SummaryState.DONE) + +# API operations +client = SymphonyAPIClient(base_url='http://symphony:8082/v1alpha2/') +# Use client for Symphony operations... +``` + +## Import Patterns + +```python +# Import from SDK root (recommended) +from agent.symphony.sdk import COARequest, COAResponse, State + +# Import specific modules +from agent.symphony.sdk.symphony_api import SymphonyAPIClient +from agent.symphony.sdk.symphony_summary import SummaryResult + +# Import everything (use carefully) +from agent.symphony.sdk import * +``` \ No newline at end of file diff --git a/agent/symphony/sdk/__init__.py b/agent/symphony/sdk/__init__.py new file mode 100644 index 0000000..2aab27d --- /dev/null +++ b/agent/symphony/sdk/__init__.py @@ -0,0 +1,84 @@ +""" +Symphony SDK Package. + +This package contains core Symphony SDK components including: +- Data structures and COA protocol +- REST API client +- Summary models +- Type definitions +""" + +# Import core SDK components for convenience +from .symphony_api import ( + SymphonyAPIClient, + SymphonyAPIError, +) +from .symphony_sdk import ( + COARequest, + COAResponse, + ComponentSpec, + DeploymentSpec, + SolutionSpec, + SymphonyProvider, + TargetSpec, + deserialize_coa_request, + deserialize_coa_response, + from_dict, + serialize_coa_request, + serialize_coa_response, + to_dict, +) +from .symphony_summary import ( + ComponentResultSpec, + SummaryResult, + SummarySpec, + SummaryState, + TargetResultSpec, + create_failed_component_result, + create_success_component_result, + create_target_result, +) +from .symphony_types import ( + COAConstants, + State, + Terminable, + get_http_status, +) + +# Export all SDK components +__all__ = [ + # API client + 'SymphonyAPIClient', + 'SymphonyAPIError', + + # Core SDK + 'COARequest', + 'COAResponse', + 'ComponentSpec', + 'DeploymentSpec', + 'SolutionSpec', + 'SymphonyProvider', + 'TargetSpec', + 'deserialize_coa_request', + 'deserialize_coa_response', + 'from_dict', + 'serialize_coa_request', + 'serialize_coa_response', + 'to_dict', + + # Summary models + 'ComponentResultSpec', + 'SummaryResult', + 'SummarySpec', + 'SummaryState', + 'TargetResultSpec', + 'create_failed_component_result', + 'create_success_component_result', + 'create_target_result', + + # Types + 'COAConstants', + 'State', + 'Terminable', + 'get_http_status', +] \ No newline at end of file diff --git a/agent/symphony/sdk/symphony_api.py b/agent/symphony/sdk/symphony_api.py new file mode 100644 index 0000000..5faf274 --- /dev/null +++ b/agent/symphony/sdk/symphony_api.py @@ -0,0 +1,682 @@ +# +# Copyright (c) 2023 Composiv.ai +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Eclipse Public License v2.0 +# and Eclipse Distribution License v1.0 which accompany this distribution. +# +# Licensed under the Eclipse Public License v2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# The Eclipse Public License is available at +# http://www.eclipse.org/legal/epl-v20.html +# and the Eclipse Distribution License is available at +# http://www.eclipse.org/org/documents/edl-v10.php. +# +# Contributors: +# Composiv.ai - initial API and implementation +# + +""" +Symphony REST API Client. + +This module provides a client for interacting with the Symphony REST API, +including authentication, target registration/unregistration, and other +Symphony operations based on the OpenAPI specification. +""" + +import requests +import json +from typing import Dict, List, Optional, Any +from datetime import datetime +import logging + + +class SymphonyAPIError(Exception): + """Custom exception for Symphony API errors.""" + + def __init__(self, message: str, status_code: Optional[int] = None, response_text: Optional[str] = None): + super().__init__(message) + self.status_code = status_code + self.response_text = response_text + + +class SymphonyAPIClient: + """ + Symphony REST API Client. + + Provides methods for interacting with the Symphony API including: + - Authentication + - Target management (register/unregister) + - Solution management + - Instance management + - Other Symphony operations + """ + + def __init__(self, base_url: str, username: str, password: str, timeout: float = 30.0, logger: Optional[logging.Logger] = None): + """ + Initialize the Symphony API client. + + Args: + base_url: Base URL of the Symphony API (e.g., 'https://symphony.example.com') + username: Symphony username for authentication + password: Symphony password for authentication + timeout: Request timeout in seconds + logger: Optional logger instance + """ + self.base_url = base_url.rstrip('/') + self.username = username + self.password = password + self.timeout = timeout + self.logger = logger or logging.getLogger(__name__) + + # Authentication state + self._access_token: Optional[str] = None + self._token_expiry: Optional[datetime] = None + + # Session for connection reuse + self._session = requests.Session() + self._session.headers.update({ + 'Content-Type': 'application/json', + 'User-Agent': 'MutoSymphonyProvider/1.0.0' + }) + + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit - close session.""" + self.close() + + def close(self): + """Close the HTTP session.""" + if self._session: + self._session.close() + + def _make_request(self, method: str, endpoint: str, **kwargs) -> requests.Response: + """ + Make an HTTP request to the Symphony API. + + Args: + method: HTTP method (GET, POST, PUT, DELETE, etc.) + endpoint: API endpoint (without base URL) + **kwargs: Additional arguments for requests + + Returns: + requests.Response object + + Raises: + SymphonyAPIError: If the request fails + """ + url = f"{self.base_url}/{endpoint.lstrip('/')}" + + # Set default timeout + kwargs.setdefault('timeout', self.timeout) + + try: + self.logger.debug(f"Making {method} request to {url}") + response = self._session.request(method, url, **kwargs) + + self.logger.debug(f"Response: {response.status_code} {response.reason}") + + return response + + except requests.exceptions.RequestException as e: + self.logger.error(f"Request failed: {e}") + raise SymphonyAPIError(f"Request failed: {str(e)}") + + def _handle_response(self, response: requests.Response, expected_codes: List[int] = None) -> Dict[str, Any]: + """ + Handle API response and extract JSON data. + + Args: + response: requests.Response object + expected_codes: List of expected HTTP status codes (default: [200]) + + Returns: + Parsed JSON response data + + Raises: + SymphonyAPIError: If response indicates an error + """ + if expected_codes is None: + expected_codes = [200] + + if response.status_code not in expected_codes: + error_msg = f"API request failed with status {response.status_code}: {response.reason}" + self.logger.error(error_msg) + self.logger.error(f"Response body: {response.text}") + raise SymphonyAPIError(error_msg, response.status_code, response.text) + + # Handle empty responses + if not response.content.strip(): + return {} + + try: + return response.json() + except json.JSONDecodeError as e: + self.logger.error(f"Failed to parse JSON response: {e}") + self.logger.error(f"Response body: {response.text}") + raise SymphonyAPIError(f"Invalid JSON response: {str(e)}") + + def authenticate(self, force_refresh: bool = False) -> str: + """ + Authenticate with Symphony API and return access token. + + Args: + force_refresh: Force token refresh even if current token is valid + + Returns: + Access token string + + Raises: + SymphonyAPIError: If authentication fails + """ + # Check if we have a valid token + if not force_refresh and self._access_token and self._token_expiry: + if datetime.utcnow() < self._token_expiry: + return self._access_token + + self.logger.info(f"Authenticating with Symphony API as user '{self.username}'") + + auth_payload = { + "username": self.username, + "password": self.password + } + + response = self._make_request('POST', '/users/auth', json=auth_payload) + data = self._handle_response(response) + + access_token = data.get('accessToken') + if not access_token: + raise SymphonyAPIError("No access token in authentication response") + + self._access_token = access_token + # Assume token is valid for 1 hour (adjust based on Symphony configuration) + self._token_expiry = datetime.utcnow().replace(microsecond=0).replace(second=0) + self._token_expiry = self._token_expiry.replace(minute=(self._token_expiry.minute + 50) % 60) + + # Update session headers with token + self._session.headers.update({ + 'Authorization': f'Bearer {access_token}' + }) + + self.logger.info("Successfully authenticated with Symphony API") + return access_token + + def _ensure_authenticated(self): + """Ensure we have a valid authentication token.""" + if not self._access_token: + self.authenticate() + # Token refresh is handled automatically in authenticate() + + # Target Management Methods + + def register_target(self, target_name: str, target_spec: Dict[str, Any]) -> Dict[str, Any]: + """ + Register a target with Symphony. + + Args: + target_name: Name of the target to register + target_spec: Target specification dictionary + + Returns: + API response data + + Raises: + SymphonyAPIError: If registration fails + """ + self._ensure_authenticated() + + self.logger.info(f"Registering target '{target_name}' with Symphony") + + response = self._make_request( + 'POST', + f'/targets/registry/{target_name}', + json=target_spec + ) + + data = self._handle_response(response, [200, 201]) + self.logger.info(f"Successfully registered target '{target_name}'") + + return data + + def unregister_target(self, target_name: str, direct: bool = False) -> Dict[str, Any]: + """ + Unregister a target from Symphony. + + Args: + target_name: Name of the target to unregister + direct: Whether to use direct delete + + Returns: + API response data + + Raises: + SymphonyAPIError: If unregistration fails + """ + self._ensure_authenticated() + + self.logger.info(f"Unregistering target '{target_name}' from Symphony") + + params = {'direct': 'true'} if direct else {} + + response = self._make_request( + 'DELETE', + f'/targets/registry/{target_name}', + params=params + ) + + data = self._handle_response(response, [200, 204]) + self.logger.info(f"Successfully unregistered target '{target_name}'") + + return data + + def get_target(self, target_name: str, doc_type: str = 'yaml', path: str = '$.spec') -> Dict[str, Any]: + """ + Get target specification. + + Args: + target_name: Name of the target + doc_type: Document type (yaml, json) + path: JSONPath to extract from response + + Returns: + Target specification data + + Raises: + SymphonyAPIError: If request fails + """ + self._ensure_authenticated() + + params = { + 'doc-type': doc_type, + 'path': path + } + + response = self._make_request( + 'GET', + f'/targets/registry/{target_name}', + params=params + ) + + return self._handle_response(response) + + def list_targets(self) -> Dict[str, Any]: + """ + List all registered targets. + + Returns: + List of targets + + Raises: + SymphonyAPIError: If request fails + """ + self._ensure_authenticated() + + response = self._make_request('GET', '/targets/registry') + return self._handle_response(response) + + def ping_target(self, target_name: str) -> Dict[str, Any]: + """ + Send heartbeat ping to target. + + Args: + target_name: Name of the target to ping + + Returns: + Ping response data + + Raises: + SymphonyAPIError: If request fails + """ + self._ensure_authenticated() + + response = self._make_request('GET', f'/targets/ping/{target_name}') + return self._handle_response(response) + + def update_target_status(self, target_name: str, status_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Update target status. + + Args: + target_name: Name of the target + status_data: Status information dictionary + + Returns: + API response data + + Raises: + SymphonyAPIError: If request fails + """ + self._ensure_authenticated() + + response = self._make_request( + 'PUT', + f'/targets/status/{target_name}', + json=status_data + ) + + return self._handle_response(response) + + # Solution Management Methods + + def create_solution(self, solution_name: str, solution_spec: str, embed_type: Optional[str] = None, + embed_component: Optional[str] = None, embed_property: Optional[str] = None) -> Dict[str, Any]: + """ + Create a solution with embedded specification. + + Args: + solution_name: Name of the solution + solution_spec: Solution specification as text + embed_type: Optional embed type + embed_component: Optional embed component + embed_property: Optional embed property + + Returns: + API response data + + Raises: + SymphonyAPIError: If request fails + """ + self._ensure_authenticated() + + params = {} + if embed_type: + params['embed-type'] = embed_type + if embed_component: + params['embed-component'] = embed_component + if embed_property: + params['embed-property'] = embed_property + + response = self._make_request( + 'POST', + f'/solutions/{solution_name}', + data=solution_spec, + params=params, + headers={'Content-Type': 'text/plain'} + ) + + return self._handle_response(response) + + def get_solution(self, solution_name: str, doc_type: str = 'yaml', path: str = '$.spec') -> Dict[str, Any]: + """ + Get solution specification. + + Args: + solution_name: Name of the solution + doc_type: Document type (yaml, json) + path: JSONPath to extract from response + + Returns: + Solution specification data + + Raises: + SymphonyAPIError: If request fails + """ + self._ensure_authenticated() + + params = { + 'doc-type': doc_type, + 'path': path + } + + response = self._make_request( + 'GET', + f'/solutions/{solution_name}', + params=params + ) + + return self._handle_response(response) + + def delete_solution(self, solution_name: str) -> Dict[str, Any]: + """ + Delete a solution. + + Args: + solution_name: Name of the solution to delete + + Returns: + API response data + + Raises: + SymphonyAPIError: If request fails + """ + self._ensure_authenticated() + + response = self._make_request('DELETE', f'/solutions/{solution_name}') + return self._handle_response(response) + + def list_solutions(self) -> Dict[str, Any]: + """ + List all solutions. + + Returns: + List of solutions + + Raises: + SymphonyAPIError: If request fails + """ + self._ensure_authenticated() + + response = self._make_request('GET', '/solutions') + return self._handle_response(response) + + # Instance Management Methods + + def create_instance(self, instance_name: str, instance_spec: Dict[str, Any]) -> Dict[str, Any]: + """ + Create an instance. + + Args: + instance_name: Name of the instance + instance_spec: Instance specification dictionary + + Returns: + API response data + + Raises: + SymphonyAPIError: If request fails + """ + self._ensure_authenticated() + + response = self._make_request( + 'POST', + f'/instances/{instance_name}', + json=instance_spec + ) + + return self._handle_response(response) + + def get_instance(self, instance_name: str, doc_type: str = 'yaml', path: str = '$.spec') -> Dict[str, Any]: + """ + Get instance specification. + + Args: + instance_name: Name of the instance + doc_type: Document type (yaml, json) + path: JSONPath to extract from response + + Returns: + Instance specification data + + Raises: + SymphonyAPIError: If request fails + """ + self._ensure_authenticated() + + params = { + 'doc-type': doc_type, + 'path': path + } + + response = self._make_request( + 'GET', + f'/instances/{instance_name}', + params=params + ) + + return self._handle_response(response) + + def delete_instance(self, instance_name: str) -> Dict[str, Any]: + """ + Delete an instance. + + Args: + instance_name: Name of the instance to delete + + Returns: + API response data + + Raises: + SymphonyAPIError: If request fails + """ + self._ensure_authenticated() + + response = self._make_request('DELETE', f'/instances/{instance_name}') + return self._handle_response(response) + + def list_instances(self) -> Dict[str, Any]: + """ + List all instances. + + Returns: + List of instances + + Raises: + SymphonyAPIError: If request fails + """ + self._ensure_authenticated() + + response = self._make_request('GET', '/instances') + return self._handle_response(response) + + # Solution Operations (for deployments) + + def apply_deployment(self, deployment_spec: Dict[str, Any]) -> Dict[str, Any]: + """ + Apply a deployment (POST to /solution/instances). + + Args: + deployment_spec: Deployment specification dictionary + + Returns: + API response data + + Raises: + SymphonyAPIError: If request fails + """ + self._ensure_authenticated() + + response = self._make_request('POST', '/solution/instances', json=deployment_spec) + return self._handle_response(response) + + def get_deployment_components(self) -> Dict[str, Any]: + """ + Get deployment components (GET /solution/instances). + + Returns: + Components data + + Raises: + SymphonyAPIError: If request fails + """ + self._ensure_authenticated() + + response = self._make_request('GET', '/solution/instances') + return self._handle_response(response) + + def delete_deployment_components(self) -> Dict[str, Any]: + """ + Delete deployment components (DELETE /solution/instances). + + Returns: + API response data + + Raises: + SymphonyAPIError: If request fails + """ + self._ensure_authenticated() + + response = self._make_request('DELETE', '/solution/instances') + return self._handle_response(response) + + def reconcile_solution(self, deployment_spec: Dict[str, Any], delete: bool = False) -> Dict[str, Any]: + """ + Direct reconcile/delete deployment (POST to /solution/reconcile). + + Args: + deployment_spec: Deployment specification dictionary + delete: Whether this is a delete operation + + Returns: + API response data + + Raises: + SymphonyAPIError: If request fails + """ + self._ensure_authenticated() + + params = {'delete': 'true'} if delete else {} + + response = self._make_request( + 'POST', + '/solution/reconcile', + json=deployment_spec, + params=params + ) + + return self._handle_response(response) + + def get_instance_status(self, instance_name: str) -> Dict[str, Any]: + """ + Get instance status (GET /solution/queue). + + Args: + instance_name: Name of the instance + + Returns: + Instance status data + + Raises: + SymphonyAPIError: If request fails + """ + self._ensure_authenticated() + + params = {'instance': instance_name} + + response = self._make_request('GET', '/solution/queue', params=params) + return self._handle_response(response) + + # Utility Methods + + def health_check(self) -> bool: + """ + Perform a basic health check of the Symphony API. + + Returns: + True if API is accessible, False otherwise + """ + try: + # Try a simple endpoint that doesn't require auth + response = self._make_request('GET', '/greetings', json={'foo': 'bar'}) + return response.status_code == 200 + except Exception as e: + self.logger.error(f"Health check failed: {e}") + return False + + def get_api_config(self) -> Dict[str, Any]: + """ + Get Symphony API configuration (GET /settings/config). + + Returns: + Configuration data + + Raises: + SymphonyAPIError: If request fails + """ + self._ensure_authenticated() + + response = self._make_request('GET', '/settings/config') + return self._handle_response(response) \ No newline at end of file diff --git a/agent/symphony/sdk/symphony_sdk.py b/agent/symphony/sdk/symphony_sdk.py new file mode 100644 index 0000000..0c62b75 --- /dev/null +++ b/agent/symphony/sdk/symphony_sdk.py @@ -0,0 +1,738 @@ +# +# Copyright (c) 2023 Composiv.ai +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Eclipse Public License v2.0 +# and Eclipse Distribution License v1.0 which accompany this distribution. +# +# Licensed under the Eclipse Public License v2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# The Eclipse Public License is available at +# http://www.eclipse.org/legal/epl-v20.html +# and the Eclipse Distribution License is available at +# http://www.eclipse.org/org/documents/edl-v10.php. +# +# Contributors: +# Composiv.ai - initial API and implementation +# + +""" +Symphony SDK Data Structures for Muto Agent. + +This module provides Symphony-compatible data structures and utilities +following the official Eclipse Symphony Python SDK patterns. +""" + +# Standard library imports +import base64 +import json +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, List, Optional, Union, get_args, get_origin + +# Local imports +from .symphony_types import State + +@dataclass +class ObjectMeta: + """Object metadata following Kubernetes-style metadata.""" + namespace: str = "" + name: str = "" + labels: Optional[Dict[str, str]] = None + annotations: Optional[Dict[str, str]] = None + + +@dataclass +class TargetSelector: + """Target selector for component binding.""" + name: str = "" + selector: Optional[Dict[str, str]] = None + + +@dataclass +class BindingSpec: + """Binding specification for component deployment.""" + role: str = "" + provider: str = "" + config: Optional[Dict[str, str]] = None + +@dataclass +class TopologySpec: + """Topology specification for deployment.""" + device: str = "" + selector: Optional[Dict[str, str]] = None + bindings: Optional[List[BindingSpec]] = None + + +@dataclass +class PipelineSpec: + """Pipeline specification for data processing.""" + name: str = "" + skill: str = "" + parameters: Optional[Dict[str, str]] = None + + +@dataclass +class VersionSpec: + """Version specification for solutions.""" + solution: str = "" + percentage: int = 100 + + +@dataclass +class InstanceSpec: + """Instance specification for Symphony deployments.""" + name: str = "" + parameters: Optional[Dict[str, str]] = None + solution: str = "" + target: Optional[TargetSelector] = None + topologies: Optional[List[TopologySpec]] = None + pipelines: Optional[List[PipelineSpec]] = None + scope: str = "" + display_name: str = "" + metadata: Optional[Dict[str, str]] = None + versions: Optional[List[VersionSpec]] = None + arguments: Optional[Dict[str, Dict[str, str]]] = None + opt_out_reconciliation: bool = False + + +@dataclass +class FilterSpec: + """Filter specification for routing.""" + direction: str = "" + parameters: Optional[Dict[str, str]] = None + type: str = "" + +@dataclass +class RouteSpec: + route: str = "" + properties: Dict[str, str] = None + filters: List[FilterSpec] = None + type: str = "" + +@dataclass +class ComponentSpec: + name: str = "" + type: str = "" + routes: List[RouteSpec] = None + constraints: str = "" + properties: Dict[str, str] = None + depedencies: List[str] = None + skills: List[str] = None + metadata: Dict[str, str] = None + parameters: Dict[str, str] = None + + +@dataclass +class SolutionSpec: + components: List[ComponentSpec] = None + scope: str = "" + displayName: str = "" + metadata: Dict[str,str] = None + +@dataclass +class SolutionState: + metadata: ObjectMeta = None + spec: SolutionSpec = None + +@dataclass +class TargetSpec: + properties: Dict[str, str] = None + components: List[ComponentSpec] = None + constraints: str = "" + topologies: List[TopologySpec] = None + scope: str = "" + displayName: str = "" + metadata: Dict[str, str] = None + forceRedeploy: bool = False + +@dataclass +class ComponentError: + code: str = "" + message: str = "" + target: str = "" + +@dataclass +class TargetError: + code: str = "" + message: str = "" + target: str = "" + details: Dict[str, ComponentError] = None + +@dataclass +class ErrorType: + code: str = "" + message: str = "" + target: str = "" + details: Dict[str, TargetError] = None + +@dataclass +class ProvisioningStatus: + operationId: str = "" + status: str = "" + failureCause: str = "" + logErrors: bool = False + error: ErrorType = None + output: Dict[str, str] = None + +@dataclass +class TargetStatus: + properties: Dict[str, str] = None + provisioningStatus: ProvisioningStatus = None + lastModififed: str = "" + +@dataclass +class TargetState: + metadata: ObjectMeta = None + spec: TargetSpec = None + status: TargetStatus = None + +@dataclass +class DeviceSpec: + properties: Dict[str, str] = None + bindings: List[BindingSpec] = None + displayName: str = "" + +@dataclass +class DeploymentSpec: + solutionName: str = "" + solution: SolutionState = None + instance: InstanceSpec = None + targets: Dict[str, TargetState] = None + devices: List[DeviceSpec] = None + assignments: Dict[str, str] = None + componentStartIndex: int = -1 + componentEndIndex: int= -1 + activeTarget: str = "" + + def get_components_slice(self) -> List[ComponentSpec]: + if self.solution != None: + if self.componentStartIndex >= 0 and self.componentEndIndex >= 0 and self.componentEndIndex > self.componentStartIndex: + return self.solution.spec.components[self.componentStartIndex: self.componentEndIndex] + return self.solution.spec.components + return [] + +@dataclass +class ComparisonPack: + desired: List[ComponentSpec] + current: List[ComponentSpec] + + +@dataclass +class COABodyMixin: + """ + Common functionality for COA request and response body handling. + + Provides content-type aware body encoding/decoding with support for: + - "application/json": JSON objects + - "text/plain": Plain text strings + - "application/octet-stream": Binary data + """ + content_type: str = "application/json" + body: str = "" # Base64 encoded data (type determined by content_type) + + def set_body(self, data: Any, content_type: Optional[str] = None) -> None: + """ + Set body data with content type detection or explicit content type. + + Args: + data: Data to set (JSON object, string, or bytes) + content_type: Optional explicit content type override + """ + # Set content type if provided + if content_type: + self.content_type = content_type + + if self.content_type == "application/json": + # Handle JSON data + if isinstance(data, (str, bytes)): + # If it's a string/bytes, try to parse as JSON first + if isinstance(data, bytes): + data = data.decode('utf-8') + json.loads(data) # Validate JSON + self.body = base64.b64encode(data.encode('utf-8')).decode('utf-8') + else: + # Serialize object to JSON + json_str = json.dumps(data, ensure_ascii=False) + self.body = base64.b64encode(json_str.encode('utf-8')).decode('utf-8') + + elif self.content_type == "text/plain": + # Handle plain text - no encoding necessary + if isinstance(data, bytes): + self.body = data.decode('utf-8') + else: + self.body = str(data) + + elif self.content_type == "application/octet-stream": + # Handle binary data + if isinstance(data, str): + # If string, assume it's already base64 encoded + self.body = data + elif isinstance(data, bytes): + # Encode bytes to base64 + self.body = base64.b64encode(data).decode('utf-8') + else: + raise ValueError(f"Binary content type requires bytes or base64 string, got {type(data)}") + + else: + # Default fallback - treat as text + if isinstance(data, bytes): + text_str = data.decode('utf-8') + else: + text_str = str(data) + self.body = base64.b64encode(text_str.encode('utf-8')).decode('utf-8') + + def get_body(self) -> Any: + """ + Get body data decoded according to content type. + + Returns: + Decoded body data (JSON object, string, or bytes depending on content_type) + """ + if not self.body: + return None + + if self.content_type == "application/json": + # Return parsed JSON object + try: + json_str = base64.b64decode(self.body).decode('utf-8') + return json.loads(json_str) + except (ValueError, json.JSONDecodeError) as e: + raise ValueError(f"Invalid JSON in body: {e}") + + elif self.content_type == "text/plain": + # Return text string directly - no decoding necessary + return self.body + + elif self.content_type == "application/octet-stream": + # Return raw bytes + return base64.b64decode(self.body) + + else: + # Default fallback - return as string + return base64.b64decode(self.body).decode('utf-8') + + +@dataclass +class COARequest(COABodyMixin): + """ + Course of Action Request structure based on Symphony COA API. + + This corresponds to the Go struct COARequest from Symphony codebase. + The body field contains base64 encoded data, with the content type determined by content_type: + - "application/json": Base64 encoded UTF-8 string of JSON object + - "text/plain": Base64 encoded UTF-8 string of plain text + - "application/octet-stream": Base64 encoded binary data + """ + method: str = "GET" + route: str = "" + metadata: Optional[Dict[str, str]] = field(default_factory=dict) + parameters: Optional[Dict[str, str]] = field(default_factory=dict) + + def to_json_dict(self) -> Dict[str, Any]: + """Convert to JSON-serializable dictionary.""" + result = { + "method": self.method, + "route": self.route, + "content-type": self.content_type, + "body": self.body + } + if self.metadata: + result["metadata"] = self.metadata + if self.parameters: + result["parameters"] = self.parameters + return result + + +@dataclass +class COAResponse(COABodyMixin): + """ + Course of Action Response structure based on Symphony COA API. + + This corresponds to the Go struct COAResponse from Symphony codebase. + The body field contains base64 encoded data, with the content type determined by content_type: + - "application/json": Base64 encoded UTF-8 string of JSON object + - "text/plain": Base64 encoded UTF-8 string of plain text + - "application/octet-stream": Base64 encoded binary data + """ + state: State = State.OK + metadata: Optional[Dict[str, str]] = field(default_factory=dict) + redirect_uri: Optional[str] = None + + def to_json_dict(self) -> Dict[str, Any]: + """Convert to JSON-serializable dictionary.""" + result = { + "content-type": self.content_type, + "body": self.body, + "state": self.state.value + } + if self.metadata: + result["metadata"] = self.metadata + if self.redirect_uri: + result["redirectUri"] = self.redirect_uri + return result + + @classmethod + def success(cls, data: Any = None, content_type: str = "application/json") -> 'COAResponse': + """Create a success response.""" + response = cls(content_type=content_type, state=State.OK) + if data is not None: + response.set_body(data, content_type) + return response + + @classmethod + def error(cls, message: str, state: State = State.INTERNAL_ERROR, + content_type: str = "application/json") -> 'COAResponse': + """Create an error response.""" + response = cls(content_type=content_type, state=state) + if content_type == "application/json": + response.set_body({"error": message}, content_type) + elif content_type == "text/plain": + response.set_body(f"Error: {message}", content_type) + else: + response.set_body({"error": message}, "application/json") + return response + + @classmethod + def not_found(cls, message: str = "Resource not found") -> 'COAResponse': + """Create a not found response.""" + return cls.error(message, State.NOT_FOUND) + + @classmethod + def bad_request(cls, message: str = "Bad request") -> 'COAResponse': + """Create a bad request response.""" + return cls.error(message, State.BAD_REQUEST) + + +# Utility functions for COA data conversion +def to_dict(obj: Any) -> Dict[str, Any]: + """Convert dataclass object to dictionary.""" + if obj is None: + return {} + + if hasattr(obj, '__dict__'): + result = {} + for key, value in obj.__dict__.items(): + if value is not None: + if isinstance(value, list): + result[key] = [to_dict(item) for item in value] + elif isinstance(value, dict): + result[key] = {k: to_dict(v) for k, v in value.items()} + elif hasattr(value, '__dict__'): + result[key] = to_dict(value) + elif isinstance(value, Enum): + result[key] = value.value + else: + result[key] = value + return result + + return obj + + +def from_dict(data: Dict[str, Any], cls: type) -> Any: + """Convert dictionary to dataclass object.""" + if not data: + return cls() + + try: + # Handle enums + if isinstance(cls, type) and issubclass(cls, Enum): + return cls(data) + + # Handle basic types + if cls in (str, int, float, bool): + return cls(data) + + # Handle dataclass + if hasattr(cls, '__dataclass_fields__'): + kwargs = {} + for field_name, field_info in cls.__dataclass_fields__.items(): + if field_name in data: + field_type = field_info.type + field_value = data[field_name] + + # Handle Optional types + if get_origin(field_type) is Optional and type(None) in get_args(field_type): + if field_value is None: + kwargs[field_name] = None + else: + inner_type = field_type.__args__[0] + kwargs[field_name] = from_dict(field_value, inner_type) + + # Handle List types + elif hasattr(field_type, '__origin__') and field_type.__origin__ is list: + if field_value and isinstance(field_value, list): + inner_type = field_type.__args__[0] + kwargs[field_name] = [from_dict(item, inner_type) for item in field_value] + else: + kwargs[field_name] = field_value or [] + + # Handle Dict types + elif hasattr(field_type, '__origin__') and field_type.__origin__ is dict: + kwargs[field_name] = field_value or {} + + # Handle nested dataclasses + elif hasattr(field_type, '__dataclass_fields__'): + kwargs[field_name] = from_dict(field_value, field_type) + + # Handle enums + elif isinstance(field_type, type) and issubclass(field_type, Enum): + kwargs[field_name] = field_type(field_value) + + else: + kwargs[field_name] = field_value + + return cls(**kwargs) + + except Exception as e: + # Fallback: return default instance + return cls() + + +def serialize_components(components: List[ComponentSpec]) -> str: + """Serialize components to JSON string.""" + return json.dumps([to_dict(comp) for comp in components], indent=2) + + +def deserialize_components(json_str: str) -> List[ComponentSpec]: + """Deserialize JSON string to components list.""" + try: + data = json.loads(json_str) + return [from_dict(item, ComponentSpec) for item in data] + except Exception: + return [] + + +def deserialize_solution(json_str: str) -> List[SolutionState]: + """Deserialize JSON string to components list.""" + try: + data = json.loads(json_str) + return [from_dict(item, SolutionState) for item in data] + except Exception: + return [] + + +# Desrialize a DeploymentSpec object from Json String +def deserialize_deployment(json_str: str) -> List[DeploymentSpec]: + """Deserialize JSON string to DeploymentSpec list.""" + try: + data = json.loads(json_str) + return [from_dict(data, DeploymentSpec)] + except Exception as e: + print(f"Error deserializing deployment: {e}") + return [] + + +def serialize_coa_request(coa_request: COARequest) -> str: + """Serialize COARequest to JSON string.""" + return json.dumps(coa_request.to_json_dict(), indent=2) + + +def deserialize_coa_request(json_str: str) -> COARequest: + """Deserialize JSON string to COARequest.""" + try: + data = json.loads(json_str) + request = COARequest() + + # Map JSON fields to dataclass fields + if "method" in data: + request.method = data["method"] + if "route" in data: + request.route = data["route"] + if "content-type" in data: + request.content_type = data["content-type"] + if "body" in data: + body_data = data["body"] + if isinstance(body_data, str): + # Assume it's already base64 encoded JSON string + request.body = body_data + else: + # Convert object to JSON and base64 encode + request.set_body(body_data, "application/json") + if "metadata" in data: + request.metadata = data["metadata"] + if "parameters" in data: + request.parameters = data["parameters"] + + return request + except Exception as e: + print(f"Error deserializing COA request: {e}") + return COARequest() + + +def serialize_coa_response(coa_response: COAResponse) -> str: + """Serialize COAResponse to JSON string.""" + return json.dumps(coa_response.to_json_dict(), indent=2) + + +def deserialize_coa_response(json_str: str) -> COAResponse: + """Deserialize JSON string to COAResponse.""" + try: + data = json.loads(json_str) + response = COAResponse() + + # Map JSON fields to dataclass fields + if "content-type" in data: + response.content_type = data["content-type"] + if "body" in data: + body_data = data["body"] + if isinstance(body_data, str): + # Assume it's already base64 encoded JSON string + response.body = body_data + else: + # Convert object to JSON and base64 encode + response.set_body(body_data, "application/json") + if "state" in data: + try: + response.state = State(data["state"]) + except ValueError: + response.state = State.INTERNAL_ERROR + if "metadata" in data: + response.metadata = data["metadata"] + if "redirectUri" in data: + response.redirect_uri = data["redirectUri"] + + return response + except Exception as e: + print(f"Error deserializing COA response: {e}") + return COAResponse() + + + """_summary_ + +A python implementatiomnon of the SymphonyProvider ITargetProvider interface +type ITargetProvider interface { + Init(config providers.IProviderConfig) error + // get validation rules + GetValidationRule(ctx context.Context) model.ValidationRule + // get current component states from a target. The desired state is passed in as a reference + Get(ctx context.Context, deployment model.DeploymentSpec, references []model.ComponentStep) ([]model.ComponentSpec, error) + // apply components to a target + Apply(ctx context.Context, deployment model.DeploymentSpec, step model.DeploymentStep, isDryRun bool) (map[string]model.ComponentResultSpec, error) +} + """ +class SymphonyProvider: + """ + Abstract base class for Symphony providers. + + This defines the interface that all Symphony providers must implement, + following the official Symphony provider specification. + """ + def init_provider(self) -> None: + """ + Initialize the provider with the given configuration. + + Args: + config: Configuration dictionary for the provider. + """ + raise NotImplementedError("init_provider method must be implemented") + + """ +# your script needs to generate an output file that contains a map of component results. For each +# component result, the status code should be one of +# 8001: fialed to update +# 8002: failed to delete +# 8003: failed to validate component artifact +# 8004: updated (success) +# 8005: deleted (success) +# 9998: untouched - no actions are taken/necessary + +output_results='{ + "com1": { + "status": 8004, + "message": "" + }, + "com2": { + "status": 8001, + "message": "update error message" + } +}' + + """ + def apply(self, metadata: Dict[str, Any], components: List[ComponentSpec]) -> str: + """ + Apply/deploy components to the target. + + Args: + components: List of components to deploy + + Returns: + JSON string with deployment results + """ + raise NotImplementedError("apply method must be implemented") + + def remove(self, metadata: Dict[str, Any], components: List[ComponentSpec]) -> str: + """ + Remove components from the target. + + Args: + components: List of components to remove + + Returns: + JSON string with removal results + """ + raise NotImplementedError("remove method must be implemented") + + # + # to get the list components you need to return during this Get() call, you can + # read from the references parameter file. This file gives you a list of components and + # their associated actions, which can be either "update" or "delete". Your script is + # supposed to use this list as a reference (regardless of the action flag) to collect + # the current state of the corresponding components, and return the list. If a component + # doesn't exist, simply skip the component. + def get(self, metadata: Dict[str, Any], components: List[ComponentSpec]) -> str: + """ + Get current state of components on the target. + + Args: + components: List of components to query (empty for all) + + Returns: + JSON string with current component states + """ + raise NotImplementedError("get method must be implemented") + + def needs_update(self, metadata: Dict[str, Any], pack: ComparisonPack) -> bool: + """ + Check if components need to be updated. + + Args: + pack: Comparison pack with desired vs current states + + Returns: + True if update is needed, False otherwise + """ + raise NotImplementedError("needs_update method must be implemented") + + """ +# your script needs to generate an output file that contains a map of component results. For each +# component result, the status code should be one of +# 8001: fialed to update +# 8002: failed to delete +# 8003: failed to validate component artifact +# 8004: updated (success) +# 8005: deleted (success) +# 9998: untouched - no actions are taken/necessary + +output_results='{ + "com1": { + "status": 8004, + "message": "" + }, + "com2": { + "status": 8001, + "message": "update error message" + } +}'""" + def needs_remove(self, metadata: Dict[str, Any], pack: ComparisonPack) -> bool: + """ + Check if components need to be removed. + + Args: + pack: Comparison pack with desired vs current states + + Returns: + True if removal is needed, False otherwise + """ + raise NotImplementedError("needs_remove method must be implemented") \ No newline at end of file diff --git a/agent/symphony/sdk/symphony_summary.py b/agent/symphony/sdk/symphony_summary.py new file mode 100644 index 0000000..4bfe1f0 --- /dev/null +++ b/agent/symphony/sdk/symphony_summary.py @@ -0,0 +1,350 @@ +""" +Symphony API Summary Models - Python Translation + +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +SPDX-License-Identifier: MIT + +This module provides Python translations of the Symphony API summary models +from the original Go implementation. +""" + +import time +from dataclasses import dataclass, field +from enum import IntEnum +from typing import Dict, Optional +from datetime import datetime +from .symphony_types import State + + +class SummaryState(IntEnum): + """ + State enumeration for Symphony summary operations. + """ + PENDING = 0 # Currently unused + RUNNING = 1 # Indicates that a reconcile operation is in progress + DONE = 2 # Indicates that a reconcile operation has completed (successfully or unsuccessfully) + + +@dataclass +class ComponentResultSpec: + """ + Result specification for a single component operation. + + Attributes: + status: State indicating success/failure of the component operation + message: Optional message with details about the operation + """ + status: State = State.OK + message: str = "" + + def to_dict(self) -> Dict[str, any]: + """Convert to dictionary representation.""" + return { + "status": self.status.value, + "message": self.message + } + + @classmethod + def from_dict(cls, data: Dict[str, any]) -> 'ComponentResultSpec': + """Create instance from dictionary.""" + return cls( + status=State(data.get("status", State.OK.value)), + message=data.get("message", "") + ) + + +@dataclass +class TargetResultSpec: + """ + Result specification for a target containing multiple components. + + Attributes: + status: Overall status string for the target + message: Optional message with target-level details + component_results: Map of component name to component result + """ + status: str = "OK" + message: str = "" + component_results: Dict[str, ComponentResultSpec] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, any]: + """Convert to dictionary representation.""" + result = { + "status": self.status + } + if self.message: + result["message"] = self.message + if self.component_results: + result["components"] = { + name: comp_result.to_dict() + for name, comp_result in self.component_results.items() + } + return result + + @classmethod + def from_dict(cls, data: Dict[str, any]) -> 'TargetResultSpec': + """Create instance from dictionary.""" + component_results = {} + if "components" in data: + component_results = { + name: ComponentResultSpec.from_dict(comp_data) + for name, comp_data in data["components"].items() + } + + return cls( + status=data.get("status", "OK"), + message=data.get("message", ""), + component_results=component_results + ) + + +@dataclass +class SummarySpec: + """ + Specification for deployment summary containing target and component results. + + Attributes: + target_count: Total number of targets + success_count: Number of successful deployments + planned_deployment: Number of planned deployments + current_deployed: Number of currently deployed components + target_results: Map of target name to target result + summary_message: Overall summary message + job_id: Optional job identifier + skipped: Whether the deployment was skipped + is_removal: Whether this is a removal operation + all_assigned_deployed: Whether all assigned components are deployed + removed: Whether components were removed + """ + target_count: int = 0 + success_count: int = 0 + planned_deployment: int = 0 + current_deployed: int = 0 + target_results: Dict[str, TargetResultSpec] = field(default_factory=dict) + summary_message: str = "" + job_id: str = "" + skipped: bool = False + is_removal: bool = False + all_assigned_deployed: bool = False + removed: bool = False + + def update_target_result(self, target: str, spec: TargetResultSpec) -> None: + """ + Update target result, merging with existing result if present. + + Args: + target: Target name + spec: New target result specification + """ + if target not in self.target_results: + self.target_results[target] = spec + else: + existing = self.target_results[target] + + # Update status - use new status if it's not "OK" + status = existing.status + if spec.status != "OK": + status = spec.status + + # Merge messages + message = existing.message + if spec.message: + if message: + message += "; " + message += spec.message + + # Merge component results + merged_components = existing.component_results.copy() + merged_components.update(spec.component_results) + + # Update the existing result + existing.status = status + existing.message = message + existing.component_results = merged_components + + self.target_results[target] = existing + + def generate_status_message(self) -> str: + """ + Generate a detailed status message from target and component results. + + Returns: + Formatted status message with error details + """ + if self.all_assigned_deployed: + return "" + + error_message = "Failed to deploy" + if self.summary_message: + error_message += f": {self.summary_message}" + error_message += ". " + + # Get target names and sort them for consistent output + target_names = sorted(self.target_results.keys()) + + # Build target errors in sorted order + target_errors = [] + for target in target_names: + result = self.target_results[target] + target_error = f'{target}: "{result.message}"' + + # Get component names and sort them for consistency + component_names = sorted(result.component_results.keys()) + + # Add component results in sorted order + for component in component_names: + component_result = result.component_results[component] + target_error += f" ({target}.{component}: {component_result.message})" + + target_errors.append(target_error) + + return error_message + f"Detailed status: {', '.join(target_errors)}" + + def to_dict(self) -> Dict[str, any]: + """Convert to dictionary representation.""" + result = { + "targetCount": self.target_count, + "successCount": self.success_count, + "plannedDeployment": self.planned_deployment, + "currentDeployed": self.current_deployed, + "skipped": self.skipped, + "isRemoval": self.is_removal, + "allAssignedDeployed": self.all_assigned_deployed, + "removed": self.removed + } + + if self.target_results: + result["targets"] = { + name: target_result.to_dict() + for name, target_result in self.target_results.items() + } + if self.summary_message: + result["message"] = self.summary_message + if self.job_id: + result["jobID"] = self.job_id + + return result + + @classmethod + def from_dict(cls, data: Dict[str, any]) -> 'SummarySpec': + """Create instance from dictionary.""" + target_results = {} + if "targets" in data: + target_results = { + name: TargetResultSpec.from_dict(target_data) + for name, target_data in data["targets"].items() + } + + return cls( + target_count=data.get("targetCount", 0), + success_count=data.get("successCount", 0), + planned_deployment=data.get("plannedDeployment", 0), + current_deployed=data.get("currentDeployed", 0), + target_results=target_results, + summary_message=data.get("message", ""), + job_id=data.get("jobID", ""), + skipped=data.get("skipped", False), + is_removal=data.get("isRemoval", False), + all_assigned_deployed=data.get("allAssignedDeployed", False), + removed=data.get("removed", False) + ) + + +@dataclass +class SummaryResult: + """ + Complete summary result for a deployment operation. + + Attributes: + summary: The summary specification with all results + summary_id: Optional unique identifier for the summary + generation: Generation string for versioning + time: Timestamp when the summary was created + state: Current state of the summary operation + deployment_hash: Hash of the deployment configuration + """ + summary: SummarySpec = field(default_factory=SummarySpec) + summary_id: str = "" + generation: str = "" + time: datetime = field(default_factory=datetime.now) + state: SummaryState = SummaryState.PENDING + deployment_hash: str = "" + + def is_deployment_finished(self) -> bool: + """ + Check if the deployment operation has finished. + + Returns: + True if deployment is done (successfully or unsuccessfully) + """ + return self.state == SummaryState.DONE + + def to_dict(self) -> Dict[str, any]: + """Convert to dictionary representation.""" + return { + "summary": self.summary.to_dict(), + "summaryid": self.summary_id, + "generation": self.generation, + "time": self.time.isoformat(), + "state": self.state.value, + "deploymentHash": self.deployment_hash + } + + @classmethod + def from_dict(cls, data: Dict[str, any]) -> 'SummaryResult': + """Create instance from dictionary.""" + # Parse time string + time_obj = datetime.now() + if "time" in data: + try: + time_obj = datetime.fromisoformat(data["time"]) + except (ValueError, TypeError): + pass + + return cls( + summary=SummarySpec.from_dict(data.get("summary", {})), + summary_id=data.get("summaryid", ""), + generation=data.get("generation", ""), + time=time_obj, + state=SummaryState(data.get("state", SummaryState.PENDING.value)), + deployment_hash=data.get("deploymentHash", "") + ) + + +# Helper functions for creating common result types +def create_success_component_result(message: str = "") -> ComponentResultSpec: + """Create a successful component result.""" + return ComponentResultSpec(status=State.OK, message=message) + + +def create_failed_component_result(message: str, status: State = None) -> ComponentResultSpec: + """Create a failed component result.""" + if status is None: + status = State.INTERNAL_ERROR + return ComponentResultSpec(status=status, message=message) + + +def create_target_result(status: str = "OK", message: str = "", + component_results: Dict[str, ComponentResultSpec] = None) -> TargetResultSpec: + """Create a target result specification.""" + if component_results is None: + component_results = {} + return TargetResultSpec( + status=status, + message=message, + component_results=component_results + ) + + +# Export commonly used items +__all__ = [ + 'SummaryState', + 'ComponentResultSpec', + 'TargetResultSpec', + 'SummarySpec', + 'SummaryResult', + 'create_success_component_result', + 'create_failed_component_result', + 'create_target_result' +] \ No newline at end of file diff --git a/agent/symphony/sdk/symphony_types.py b/agent/symphony/sdk/symphony_types.py new file mode 100644 index 0000000..c604a31 --- /dev/null +++ b/agent/symphony/sdk/symphony_types.py @@ -0,0 +1,346 @@ +""" +Symphony COA API Types - Python Translation + +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +SPDX-License-Identifier: MIT + +This module provides Python translations of the Symphony COA API types +from the original Go implementation. +""" + +import abc +import asyncio +from enum import IntEnum +from typing import Protocol + + +class Terminable(Protocol): + """Interface for objects that can be gracefully terminated.""" + + @abc.abstractmethod + async def shutdown(self) -> None: + """Shutdown the object gracefully.""" + pass + + +class State(IntEnum): + """State represents a response state matching Symphony Go types.""" + + # Basic states + NONE = 0 + + # HTTP Success states + OK = 200 # HTTP 200 + ACCEPTED = 202 # HTTP 202 + + # HTTP Client Error states + BAD_REQUEST = 400 # HTTP 400 + UNAUTHORIZED = 401 # HTTP 401 + FORBIDDEN = 403 # HTTP 403 + NOT_FOUND = 404 # HTTP 404 + METHOD_NOT_ALLOWED = 405 # HTTP 405 + CONFLICT = 409 # HTTP 409 + STATUS_UNPROCESSABLE_ENTITY = 422 # HTTP 422 + + # HTTP Server Error states + INTERNAL_ERROR = 500 # HTTP 500 + + # Config errors + BAD_CONFIG = 1000 + MISSING_CONFIG = 1001 + + # API invocation errors + INVALID_ARGUMENT = 2000 + API_REDIRECT = 3030 + + # IO errors + FILE_ACCESS_ERROR = 4000 + + # Serialization errors + SERIALIZATION_ERROR = 5000 + DESERIALIZE_ERROR = 5001 + + # Async requests + DELETE_REQUESTED = 6000 + + # Operation results + UPDATE_FAILED = 8001 + DELETE_FAILED = 8002 + VALIDATE_FAILED = 8003 + UPDATED = 8004 + DELETED = 8005 + + # Workflow status + RUNNING = 9994 + PAUSED = 9995 + DONE = 9996 + DELAYED = 9997 + UNTOUCHED = 9998 + NOT_IMPLEMENTED = 9999 + + # Detailed error codes + INIT_FAILED = 10000 + CREATE_ACTION_CONFIG_FAILED = 10001 + HELM_ACTION_FAILED = 10002 + GET_COMPONENT_SPEC_FAILED = 10003 + CREATE_PROJECTOR_FAILED = 10004 + K8S_REMOVE_SERVICE_FAILED = 10005 + K8S_REMOVE_DEPLOYMENT_FAILED = 10006 + K8S_DEPLOYMENT_FAILED = 10007 + READ_YAML_FAILED = 10008 + APPLY_YAML_FAILED = 10009 + READ_RESOURCE_PROPERTY_FAILED = 10010 + APPLY_RESOURCE_FAILED = 10011 + DELETE_YAML_FAILED = 10012 + DELETE_RESOURCE_FAILED = 10013 + CHECK_RESOURCE_STATUS_FAILED = 10014 + APPLY_SCRIPT_FAILED = 10015 + REMOVE_SCRIPT_FAILED = 10016 + YAML_RESOURCE_PROPERTY_NOT_FOUND = 10017 + GET_HELM_PROPERTY_FAILED = 10018 + HELM_CHART_PULL_FAILED = 10019 + HELM_CHART_LOAD_FAILED = 10020 + HELM_CHART_APPLY_FAILED = 10021 + HELM_CHART_UNINSTALL_FAILED = 10022 + INGRESS_APPLY_FAILED = 10023 + HTTP_NEW_REQUEST_FAILED = 10024 + HTTP_SEND_REQUEST_FAILED = 10025 + HTTP_ERROR_RESPONSE = 10026 + MQTT_PUBLISH_FAILED = 10027 + MQTT_APPLY_FAILED = 10028 + MQTT_APPLY_TIMEOUT = 10029 + CONFIG_MAP_APPLY_FAILED = 10030 + HTTP_BAD_WAIT_STATUS_CODE = 10031 + HTTP_NEW_WAIT_REQUEST_FAILED = 10032 + HTTP_SEND_WAIT_REQUEST_FAILED = 10033 + HTTP_ERROR_WAIT_RESPONSE = 10034 + HTTP_BAD_WAIT_EXPRESSION = 10035 + SCRIPT_EXECUTION_FAILED = 10036 + SCRIPT_RESULT_PARSING_FAILED = 10037 + WAIT_TO_GET_INSTANCES_FAILED = 10038 + WAIT_TO_GET_SITES_FAILED = 10039 + WAIT_TO_GET_CATALOGS_FAILED = 10040 + INVALID_WAIT_OBJECT_TYPE = 10041 + CATALOGS_GET_FAILED = 10042 + INVALID_INSTANCE_CATALOG = 10043 + CREATE_INSTANCE_FROM_CATALOG_FAILED = 10044 + INVALID_SOLUTION_CATALOG = 10045 + CREATE_SOLUTION_FROM_CATALOG_FAILED = 10046 + INVALID_TARGET_CATALOG = 10047 + CREATE_TARGET_FROM_CATALOG_FAILED = 10048 + INVALID_CATALOG_CATALOG = 10049 + CREATE_CATALOG_FROM_CATALOG_FAILED = 10050 + PARENT_OBJECT_MISSING = 10051 + PARENT_OBJECT_CREATE_FAILED = 10052 + MATERIALIZE_BATCH_FAILED = 10053 + DELETE_INSTANCE_FAILED = 10054 + CREATE_INSTANCE_FAILED = 10055 + DEPLOYMENT_NOT_REACHED = 10056 + INVALID_OBJECT_TYPE = 10057 + UNSUPPORTED_ACTION = 10058 + INSTANCE_GET_FAILED = 10059 + TARGET_GET_FAILED = 10060 + DELETE_SOLUTION_FAILED = 10061 + CREATE_SOLUTION_FAILED = 10062 + GET_ARM_DEPLOYMENT_PROPERTY_FAILED = 10071 + ENSURE_ARM_RESOURCE_GROUP_FAILED = 10072 + CREATE_ARM_DEPLOYMENT_FAILED = 10073 + CLEANUP_ARM_DEPLOYMENT_FAILED = 10074 + + # Instance controller errors + SOLUTION_GET_FAILED = 11000 + TARGET_CANDIDATES_NOT_FOUND = 11001 + TARGET_LIST_GET_FAILED = 11002 + OBJECT_INSTANCE_CONVERSION_FAILED = 11003 + TIMED_OUT = 11004 + + # Target controller errors + TARGET_PROPERTY_NOT_FOUND = 12000 + + # Non-transient errors + GET_COMPONENT_PROPS_FAILED = 50000 + + def __str__(self) -> str: + """Return human-readable string representation of the state.""" + state_strings = { + State.OK: "OK", + State.ACCEPTED: "Accepted", + State.BAD_REQUEST: "Bad Request", + State.UNAUTHORIZED: "Unauthorized", + State.FORBIDDEN: "Forbidden", + State.NOT_FOUND: "Not Found", + State.METHOD_NOT_ALLOWED: "Method Not Allowed", + State.CONFLICT: "Conflict", + State.STATUS_UNPROCESSABLE_ENTITY: "Unprocessable Entity", + State.INTERNAL_ERROR: "Internal Error", + State.BAD_CONFIG: "Bad Config", + State.MISSING_CONFIG: "Missing Config", + State.INVALID_ARGUMENT: "Invalid Argument", + State.API_REDIRECT: "API Redirect", + State.FILE_ACCESS_ERROR: "File Access Error", + State.SERIALIZATION_ERROR: "Serialization Error", + State.DESERIALIZE_ERROR: "De-serialization Error", + State.DELETE_REQUESTED: "Delete Requested", + State.UPDATE_FAILED: "Update Failed", + State.DELETE_FAILED: "Delete Failed", + State.VALIDATE_FAILED: "Validate Failed", + State.UPDATED: "Updated", + State.DELETED: "Deleted", + State.RUNNING: "Running", + State.PAUSED: "Paused", + State.DONE: "Done", + State.DELAYED: "Delayed", + State.UNTOUCHED: "Untouched", + State.NOT_IMPLEMENTED: "Not Implemented", + State.INIT_FAILED: "Init Failed", + State.CREATE_ACTION_CONFIG_FAILED: "Create Action Config Failed", + State.HELM_ACTION_FAILED: "Helm Action Failed", + State.GET_COMPONENT_SPEC_FAILED: "Get Component Spec Failed", + State.CREATE_PROJECTOR_FAILED: "Create Projector Failed", + State.K8S_REMOVE_SERVICE_FAILED: "Remove K8s Service Failed", + State.K8S_REMOVE_DEPLOYMENT_FAILED: "Remove K8s Deployment Failed", + State.K8S_DEPLOYMENT_FAILED: "K8s Deployment Failed", + State.READ_YAML_FAILED: "Read Yaml Failed", + State.APPLY_YAML_FAILED: "Apply Yaml Failed", + State.READ_RESOURCE_PROPERTY_FAILED: "Read Resource Property Failed", + State.APPLY_RESOURCE_FAILED: "Apply Resource Failed", + State.DELETE_YAML_FAILED: "Delete Yaml Failed", + State.DELETE_RESOURCE_FAILED: "Delete Resource Failed", + State.CHECK_RESOURCE_STATUS_FAILED: "Check Resource Status Failed", + State.APPLY_SCRIPT_FAILED: "Apply Script Failed", + State.REMOVE_SCRIPT_FAILED: "Remove Script Failed", + State.YAML_RESOURCE_PROPERTY_NOT_FOUND: "Yaml or Resource Property Not Found", + State.GET_HELM_PROPERTY_FAILED: "Get Helm Property Failed", + State.HELM_CHART_PULL_FAILED: "Helm Chart Pull Failed", + State.HELM_CHART_LOAD_FAILED: "Helm Chart Load Failed", + State.HELM_CHART_APPLY_FAILED: "Helm Chart Apply Failed", + State.HELM_CHART_UNINSTALL_FAILED: "Helm Chart Uninstall Failed", + State.INGRESS_APPLY_FAILED: "Ingress Apply Failed", + State.HTTP_NEW_REQUEST_FAILED: "Http New Request Failed", + State.HTTP_SEND_REQUEST_FAILED: "Http Send Request Failed", + State.HTTP_ERROR_RESPONSE: "Http Error Response", + State.MQTT_PUBLISH_FAILED: "Mqtt Publish Failed", + State.MQTT_APPLY_FAILED: "Mqtt Apply Failed", + State.MQTT_APPLY_TIMEOUT: "Mqtt Apply Timeout", + State.CONFIG_MAP_APPLY_FAILED: "ConfigMap Apply Failed", + State.HTTP_BAD_WAIT_STATUS_CODE: "Http Bad Wait Status Code", + State.HTTP_NEW_WAIT_REQUEST_FAILED: "Http New Wait Request Failed", + State.HTTP_SEND_WAIT_REQUEST_FAILED: "Http Send Wait Request Failed", + State.HTTP_ERROR_WAIT_RESPONSE: "Http Error Wait Response", + State.HTTP_BAD_WAIT_EXPRESSION: "Http Bad Wait Expression", + State.SCRIPT_EXECUTION_FAILED: "Script Execution Failed", + State.SCRIPT_RESULT_PARSING_FAILED: "Script Result Parsing Failed", + State.WAIT_TO_GET_INSTANCES_FAILED: "Wait To Get Instances Failed", + State.WAIT_TO_GET_SITES_FAILED: "Wait To Get Sites Failed", + State.WAIT_TO_GET_CATALOGS_FAILED: "Wait To Get Catalogs Failed", + State.INVALID_WAIT_OBJECT_TYPE: "Invalid Wait Object Type", + State.CATALOGS_GET_FAILED: "Get Catalogs Failed", + State.INVALID_INSTANCE_CATALOG: "Invalid Instance Catalog", + State.CREATE_INSTANCE_FROM_CATALOG_FAILED: "Create Instance From Catalog Failed", + State.INVALID_SOLUTION_CATALOG: "Invalid Solution Object in Catalog", + State.CREATE_SOLUTION_FROM_CATALOG_FAILED: "Create Solution Object From Catalog Failed", + State.INVALID_TARGET_CATALOG: "Invalid Target Object in Catalog", + State.CREATE_TARGET_FROM_CATALOG_FAILED: "Create Target Object From Catalog Failed", + State.INVALID_CATALOG_CATALOG: "Invalid Catalog Object in Catalog", + State.CREATE_CATALOG_FROM_CATALOG_FAILED: "Create Catalog Object From Catalog Failed", + State.PARENT_OBJECT_MISSING: "Parent Object Missing", + State.PARENT_OBJECT_CREATE_FAILED: "Parent Object Create Failed", + State.MATERIALIZE_BATCH_FAILED: "Failed to Materialize all objects", + State.DELETE_INSTANCE_FAILED: "Failed to Delete Instance", + State.CREATE_INSTANCE_FAILED: "Failed to Create Instance", + State.DEPLOYMENT_NOT_REACHED: "Deployment Not Reached", + State.INVALID_OBJECT_TYPE: "Invalid Object Type", + State.UNSUPPORTED_ACTION: "Unsupported Action", + State.INSTANCE_GET_FAILED: "Get instance failed", + State.TARGET_GET_FAILED: "Get target failed", + State.SOLUTION_GET_FAILED: "Solution does not exist", + State.TARGET_CANDIDATES_NOT_FOUND: "Target does not exist", + State.TARGET_LIST_GET_FAILED: "Target list does not exist", + State.OBJECT_INSTANCE_CONVERSION_FAILED: "Object to Instance conversion failed", + State.TIMED_OUT: "Timed Out", + State.TARGET_PROPERTY_NOT_FOUND: "Target Property Not Found", + State.GET_COMPONENT_PROPS_FAILED: "Get component property failed", + } + + return state_strings.get(self, f"Unknown State: {self.value}") + + def equals_with_string(self, string: str) -> bool: + """Check if state equals a string representation.""" + return str(self) == string + + @classmethod + def from_http_status(cls, code: int) -> 'State': + """Get State from HTTP status code.""" + if code == 200: + return cls.OK + elif code == 202: + return cls.ACCEPTED + elif 200 <= code < 300: + return cls.OK + elif code == 401: + return cls.UNAUTHORIZED + elif code == 403: + return cls.FORBIDDEN + elif code == 404: + return cls.NOT_FOUND + elif code == 405: + return cls.METHOD_NOT_ALLOWED + elif code == 409: + return cls.CONFLICT + elif 400 <= code < 500: + return cls.BAD_REQUEST + elif code >= 500: + return cls.INTERNAL_ERROR + else: + return cls.NONE + + +# COA Constants +class COAConstants: + """Constants used in Symphony COA operations.""" + + # Header constants + COA_META_HEADER = "COA_META_HEADER" + + # Tracing and monitoring + TRACING_EXPORTER_CONSOLE = "tracing.exporters.console" + METRICS_EXPORTER_OTLP_GRPC = "metrics.exporters.otlpgrpc" + TRACING_EXPORTER_ZIPKIN = "tracing.exporters.zipkin" + TRACING_EXPORTER_OTLP_GRPC = "tracing.exporters.otlpgrpc" + LOG_EXPORTER_CONSOLE = "log.exporters.console" + LOG_EXPORTER_OTLP_GRPC = "log.exporters.otlpgrpc" + LOG_EXPORTER_OTLP_HTTP = "log.exporters.otlphttp" + + # Provider constants + PROVIDERS_PERSISTENT_STATE = "providers.persistentstate" + PROVIDERS_VOLATILE_STATE = "providers.volatilestate" + PROVIDERS_CONFIG = "providers.config" + PROVIDERS_SECRET = "providers.secret" + PROVIDERS_REFERENCE = "providers.reference" + PROVIDERS_PROBE = "providers.probe" + PROVIDERS_UPLOADER = "providers.uploader" + PROVIDERS_REPORTER = "providers.reporter" + PROVIDER_QUEUE = "providers.queue" + PROVIDER_LEDGER = "providers.ledger" + PROVIDERS_KEY_LOCK = "providers.keylock" + + # Output constants + STATUS_OUTPUT = "status" + ERROR_OUTPUT = "error" + STATE_OUTPUT = "__state" + + +# Helper functions for compatibility +def get_http_status(code: int) -> State: + """Get State from HTTP status code (compatibility function).""" + return State.from_http_status(code) + + +# Export commonly used items +__all__ = [ + 'State', + 'Terminable', + 'COAConstants', + 'get_http_status' +] \ No newline at end of file diff --git a/agent/symphony/symphony_broker.py b/agent/symphony/symphony_broker.py new file mode 100644 index 0000000..d26dbc5 --- /dev/null +++ b/agent/symphony/symphony_broker.py @@ -0,0 +1,260 @@ +# +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +# SPDX-License-Identifier: MIT +# + +""" +Symphony MQTT Broker Module. + +This module implements an MQTT broker that handles Symphony COA requests +and responses, providing an interface between Symphony orchestration and +the Muto Agent system. +""" + +# Standard library imports +import json +from typing import Any, Dict, Optional + +# Third-party imports +from paho.mqtt.client import MQTTMessage + +# Local imports +from ..mqtt_manager import MQTTConnectionManager +from .sdk.symphony_sdk import ( + COARequest, + COAResponse, + ComparisonPack, + ComponentSpec, + DeploymentSpec, + deserialize_coa_request, + from_dict, + serialize_coa_response, +) + + +class MQTTBroker: + """ + MQTT Broker for Symphony COA operations. + + This class handles MQTT communication for Symphony Component Object + Architecture (COA) requests and responses, providing an interface + between Symphony orchestration and the Muto Agent system. + + The broker translates HTTP-style requests to MQTT messages and + vice versa, following the COA protocol specifications. + """ + + def __init__(self, plugin, node, config): + """ + Initialize the MQTT broker. + + Args: + plugin: Symphony provider plugin implementing COA operations. + node: ROS 2 node for logging and lifecycle management. + config: Configuration object containing MQTT settings. + """ + self.init_provider = plugin.init_provider + self.apply = plugin.apply + self.remove = plugin.remove + self.get = plugin.get + self.needs_update = plugin.needs_update + self.needs_remove = plugin.needs_remove + self.logger = node.get_logger() + self._config = config + self._node = node + + # Initialize MQTT connection manager + self._mqtt_manager = MQTTConnectionManager( + node=node, + config=config.symphony.mqtt, + message_handler=self._handle_mqtt_message, + on_connect_handler=self._on_connect, + logger=self.logger + ) + + def _on_connect(self, client, userdata, flags, reason_code, properties): + """ + Handle MQTT connection established. + + Args: + client: MQTT client instance. + userdata: User data passed to client. + flags: Connection flags. + reason_code: Connection result code. + properties: Connection properties. + """ + self.logger.info( + f"Symphony router connected to MQTT broker with result code " + f"{reason_code}" + ) + + # Subscribe to Symphony request topic + request_topic = ( + f"{self._config.symphony.topic_prefix}/" + f"{self._config.symphony.request_topic}" + ) + topics = [request_topic] + + # Subscribe to request topics + for topic in topics: + self._mqtt_manager.subscribe(topic) + self.logger.debug(f"Subscribed to topic: {topic}") + + # Connected to MQTT broker so initialize the provider + try: + self.init_provider() + self.logger.info("Symphony provider initialized successfully") + except Exception as e: + self.logger.error(f"Failed to initialize Symphony provider: {e}") + + def _handle_mqtt_message(self, message: MQTTMessage) -> None: + """ + Handle incoming MQTT messages for Symphony operations. + + Args: + message: The MQTT message to handle. + """ + # Symphony action response topic + response_topic = ( + f"{self._config.symphony.topic_prefix}/" + f"{self._config.symphony.response_topic}" + ) + + try: + topic = message.topic + payload_str = message.payload.decode('utf-8') + + self.logger.info( + f"Symphony router received MQTT message on topic: {topic}" + ) + + # Parse the contents of the payload_str as COARequest + try: + coa_request = deserialize_coa_request(payload_str) + except Exception as e: + self.logger.error(f"Failed to parse COARequest: {e}") + error_response = COAResponse.bad_request( + f"Invalid COARequest format: {e}" + ) + error_response.metadata = {} + self._mqtt_manager.publish( + response_topic, + serialize_coa_response(error_response) + ) + return + + # Handle request based on method and route in COARequest + method = coa_request.method + route = coa_request.route + body = coa_request.get_body() if coa_request.body else {} + + response_data = self._handle_request( + coa_request.metadata, method, route, body + ) + + # Create COAResponse and publish back + if isinstance(response_data, dict) and "error" in response_data: + coa_response = COAResponse.error(response_data["error"]) + else: + coa_response = COAResponse.success(response_data) + coa_response.metadata = coa_request.metadata + self._mqtt_manager.publish( + response_topic, + serialize_coa_response(coa_response) + ) + + except Exception as e: + self.logger.error(f"Error handling MQTT message: {e}") + error_response = COAResponse.error(str(e)) + try: + error_response.metadata = coa_request.metadata + except UnboundLocalError: + error_response.metadata = {} + + self._mqtt_manager.publish( + response_topic, + serialize_coa_response(error_response) + ) + + def _handle_request( + self, + metadata: Dict[str, Any], + method: str, + route: str, + body: Dict[str, Any] + ) -> Any: + """ + Handle COA request by routing to appropriate handler. + + Args: + metadata: Request metadata. + method: HTTP method (POST, DELETE, GET). + route: Request route/endpoint. + body: Request body data. + + Returns: + Response data from the appropriate handler. + """ + if route == "instances": + if method == "POST": + return self._apply(metadata, body) + elif method == "DELETE": + return self._remove(metadata, body) + elif method == "GET": + return self._get(metadata, body) + elif route == "needsupdate": + return self._needs_update(metadata, body) + elif route == "needsremove": + return self._needs_remove(metadata, body) + else: + return {"error": "Route not found"} + + def _apply(self, metadata: Dict[str, Any], data: Dict[str, Any]) -> str: + """Apply/deploy components from deployment specification.""" + deployment = from_dict(data, DeploymentSpec) + components = deployment.get_components_slice() + return self.apply(metadata, components) + + def _remove(self, metadata: Dict[str, Any], data: Dict[str, Any]) -> str: + """Remove components from deployment specification.""" + deployment = from_dict(data, DeploymentSpec) + components = deployment.get_components_slice() + return self.remove(metadata, components) + + def _get(self, metadata: Dict[str, Any], data: Dict[str, Any]) -> Any: + """Get component states from deployment specification.""" + deployment = from_dict(data, DeploymentSpec) + components = deployment.get_components_slice() + return self.get(metadata, components) + + def _needs_update( + self, + metadata: Dict[str, Any], + data: Dict[str, Any] + ) -> bool: + """Check if components need updates from comparison pack.""" + pack = from_dict(data, ComparisonPack) + return self.needs_update(metadata, pack) + + def _needs_remove( + self, + metadata: Dict[str, Any], + data: Dict[str, Any] + ) -> bool: + """Check if components need removal from comparison pack.""" + pack = from_dict(data, ComparisonPack) + return self.needs_remove(metadata, pack) + + def connect(self) -> None: + """Connect to the MQTT broker.""" + if self._mqtt_manager: + self._mqtt_manager.connect() + self.logger.info("Symphony MQTT router connected") + + def stop(self) -> None: + """Stop the MQTT router.""" + if self._mqtt_manager: + self._mqtt_manager.disconnect() + self.logger.info("Symphony MQTT router stopped") + diff --git a/agent/symphony/symphony_provider.py b/agent/symphony/symphony_provider.py new file mode 100644 index 0000000..b378727 --- /dev/null +++ b/agent/symphony/symphony_provider.py @@ -0,0 +1,700 @@ +# +# Copyright (c) 2023 Composiv.ai +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Eclipse Public License v2.0 +# and Eclipse Distribution License v1.0 which accompany this distribution. +# +# Licensed under the Eclipse Public License v2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# The Eclipse Public License is available at +# http://www.eclipse.org/legal/epl-v20.html +# and the Eclipse Distribution License is available at +# http://www.eclipse.org/org/documents/edl-v10.php. +# +# Contributors: +# Composiv.ai - initial API and implementation +# + +""" +Muto Symphony Provider. + +This module implements a Symphony provider that integrates with the Muto Agent +MQTT infrastructure, allowing Symphony orchestrators to manage ROS 2 components +through the Muto platform. +""" + +# Standard library imports +import base64 +import json +import signal +import threading +from typing import Any, Dict, List, Optional + +# Third-party imports +import rclpy +from muto_msgs.msg import MutoAction +from rclpy.node import Node +from std_srvs.srv import Trigger + +# Local imports +from ..config import ConfigurationManager +from ..interfaces import BaseNode +from .sdk.symphony_api import SymphonyAPIClient, SymphonyAPIError +from .sdk.symphony_sdk import ( + ComponentSpec, + ComparisonPack, + SymphonyProvider, + to_dict, +) +from .sdk.symphony_summary import ( + ComponentResultSpec, + SummarySpec, + SummaryState, + TargetResultSpec, +) +from .sdk.symphony_types import State +from .symphony_broker import MQTTBroker + + +class MutoSymphonyProvider(BaseNode, SymphonyProvider): + """ + Symphony provider integrating with Muto Agent MQTT infrastructure. + + This provider implements the Symphony provider interface and manages + ROS 2 components through Muto's existing MQTT communication layer. + """ + + def __init__( + self, + config_manager: Optional[ConfigurationManager] = None, + node_name: str = 'muto_symphony_provider' + ): + """ + Initialize the Muto Symphony Provider. + + Args: + config_manager: Optional configuration manager. + node_name: ROS 2 node name. + """ + super().__init__(node_name) + + # Configuration + self._config_manager = config_manager or ConfigurationManager(self) + self._config = self._config_manager.load_config() + + # MQTT Broker for Symphony communication + self._mqtt_broker: Optional[MQTTBroker] = None + + # Symphony API client + self._api_client: Optional[SymphonyAPIClient] = None + + # Lifecycle management + self._shutdown_event = threading.Event() + self._running = False + + # Setup logging + self.logger = self.get_logger() + + # Setup stack publisher + topics = self._config.topics + self.stack_publisher = self.create_publisher( + MutoAction, topics.stack_topic, 10 + ) + + + def _do_initialize(self) -> None: + + # Create ROS services for Symphony registration + self._register_service = self.create_service( + Trigger, + 'symphony_register_target', + self._register_target_service + ) + + self._unregister_service = self.create_service( + Trigger, + 'symphony_unregister_target', + self._unregister_target_service + ) + + # Initialize Symphony API client + self._init_symphony_api() + + # Initialize MQTT Broker if enabled + if self._config.symphony.enabled: + self._init_mqtt_broker() + + + def _init_symphony_api(self) -> None: + """Initialize Symphony API client.""" + try: + symphony = self._config.symphony + self._api_client = SymphonyAPIClient( + base_url=symphony.api_url, + username=symphony.mqtt.user, + password=symphony.mqtt.password, + timeout=30.0, + logger=self.logger + ) + + self.logger.info("Symphony API client initialized") + + except Exception as e: + self.logger.error(f"Failed to initialize Symphony API client: {e}") + self._api_client = None + + def _init_mqtt_broker(self) -> None: + """Initialize MQTT broker for Symphony operations.""" + try: + # Initialize the MQTTBroker with this provider as the plugin + self._mqtt_broker = MQTTBroker( + plugin=self, # This provider implements the required methods + node=self, # ROS node + config=self._config # Configuration + ) + self._mqtt_broker.connect() + + except Exception as e: + self.logger.error(f"Failed to initialize MQTT Broker: {e}") + self._mqtt_broker = None + + + + + + def _authenticate_symphony_api(self) -> Optional[str]: + """ + Authenticate with Symphony API and return access token. + + Returns: + Access token if successful, None if failed. + """ + if not self._api_client: + self.logger.error("Symphony API client not initialized") + return None + + try: + return self._api_client.authenticate() + except SymphonyAPIError as e: + self.logger.error(f"Symphony API authentication failed: {e}") + return None + + def register_target(self) -> bool: + """ + Register this Muto agent as a target in Symphony. + + Returns: + True if registration successful, False otherwise. + """ + if not self._api_client: + self.logger.error("Symphony API client not initialized") + return False + + symphony = self._config.symphony + try: + # Build target registration payload + target_payload = { + "metadata": { + "name": symphony.target + }, + "spec": { + "displayName": symphony.target, + "forceRedeploy": True, + "topologies": [ + { + "bindings": [ + { + "role": "muto-agent", + "provider": symphony.provider_name, + "config": { + "name": "proxy", + "brokerAddress": symphony.broker_address, + "clientID": symphony.client_id, + "requestTopic": f"{symphony.topic_prefix}/{symphony.request_topic}", + "responseTopic": f"{symphony.topic_prefix}/{symphony.response_topic}", + "timeoutSeconds": symphony.timeout_seconds + } + } + ] + } + ] + } + } + + # Register target using API client + self._api_client.register_target(symphony.target, target_payload) + return True + + except SymphonyAPIError as e: + self.logger.error(f"Failed to register target with Symphony: {e}") + return False + except Exception as e: + self.logger.error(f"Unexpected error registering target: {e}") + return False + + def unregister_target(self) -> bool: + """ + Unregister this Muto agent target from Symphony. + + Returns: + True if unregistration successful, False otherwise. + """ + if not self._api_client: + self.logger.error("Symphony API client not initialized") + return False + + symphony = self._config.symphony + try: + # Unregister target using API client + self._api_client.unregister_target(symphony.target, direct=True) + return True + + except SymphonyAPIError as e: + self.logger.error(f"Failed to unregister target from Symphony: {e}") + return False + except Exception as e: + self.logger.error(f"Unexpected error unregistering target: {e}") + return False + + def _auto_register_target(self) -> None: + """ + Automatically register target with Symphony if conditions are met. + This is called after MQTT broker initialization. + """ + try: + if self._mqtt_broker: + self.logger.info("Attempting auto-registration with Symphony") + success = self.register_target() + if success: + self.logger.info("Auto-registration with Symphony completed successfully") + else: + self.logger.warn("Auto-registration with Symphony failed, will not retry automatically") + else: + self.logger.info("MQTT broker not available for auto-registration") + except Exception as e: + self.logger.error(f"Error in auto-registration: {e}") + + def _register_target_service( + self, + request: Trigger.Request, + response: Trigger.Response + ) -> Trigger.Response: + """ + ROS service callback to register target with Symphony. + + Args: + request: Service request. + response: Service response. + + Returns: + Service response with success status and message. + """ + success = self.register_target() + response.success = success + if success: + response.message = f"Successfully registered target '{self._config.symphony.target}' with Symphony" + else: + response.message = f"Failed to register target '{self._config.symphony.target}' with Symphony" + return response + + def _unregister_target_service( + self, + request: Trigger.Request, + response: Trigger.Response + ) -> Trigger.Response: + """ + ROS service callback to unregister target from Symphony. + + Args: + request: Service request. + response: Service response. + + Returns: + Service response with success status and message + """ + success = self.unregister_target() + response.success = success + if success: + response.message = f"Successfully unregistered target '{self._config.symphony.target}' from Symphony" + else: + response.message = f"Failed to unregister target '{self._config.symphony.target}' from Symphony" + return response + + # SymphonyProvider Interface Methods + # These are called by MQTTBroker when it receives Symphony requests + + def init_provider(self): + """Initialize the provider - required by MQTTBroker interface.""" + self.logger.info(f"Muto Symphony Provider initialized - Target: {self._config.symphony.target}") + # Auto-register target after initialization + self.register_target() + + def apply( + self, + metadata: Dict[str, Any], + components: List[ComponentSpec] + ) -> str: + """ + Apply/deploy components. + + Called by MQTTBroker when Symphony sends deployment requests. + This method implements the core deployment logic for Symphony + components. + + Args: + metadata: Request metadata. + components: List of components to deploy. + + Returns: + JSON string containing deployment results. + """ + self.logger.info( + f"Symphony apply: deploying {len(components)} components" + ) + + # Use summary models + result = SummarySpec(target_count=1, success_count=1) + target_result = TargetResultSpec() + + for component in components: + self.logger.info( + f"Deploying component: {component.name} of type " + f"{component.type}" + ) + + component_type = component.properties.get("type", "") + content_type = component.properties.get("content-type", "") + data = component.properties.get("data", "") + + if (component_type == "stack" and + content_type == "application/json" and + data): + try: + stack_json_str = base64.b64decode(data).decode('utf-8') + stack_data = json.loads(stack_json_str) + self.logger.info( + f"Decoded stack data for {component_type} " + f"{component.name}: {stack_data}" + ) + + msg_action = MutoAction() + msg_action.context = "" + msg_action.method = "start" + msg_action.payload = json.dumps(stack_data) + self.stack_publisher.publish(msg_action) + + # Implement actual deployment logic using stack_data + except Exception as e: + self.logger.error( + f"Failed to decode or parse stack data for " + f"{component_type} {component.name}: {e}" + ) + + component_result = ComponentResultSpec() + component_result.status = State.UPDATED + component_result.message = ( + f"Deploying component: {component.name} of type " + f"{component.type}" + ) + + target_result.component_results[component.name] = component_result + self.logger.info( + f"Deploying component: {component.name} of type " + f"{component.type}" + ) + + target_result.state = SummaryState.DONE + result.update_target_result(metadata["active-target"], target_result) + + return json.dumps(result.to_dict(), indent=2) + + def remove( + self, + metadata: Dict[str, Any], + components: List[ComponentSpec] + ) -> str: + """ + Remove components. + + Called by MQTTBroker when Symphony sends removal requests. + This method implements the core removal logic for Symphony components. + + Args: + metadata: Request metadata. + components: List of components to remove. + + Returns: + JSON string containing removal results. + """ + self.logger.info( + f"Symphony remove: removing {len(components)} components" + ) + + results = [] + + return json.dumps([to_dict(result) for result in results], indent=2) + + def get( + self, + metadata: Dict[str, Any], + components: List[ComponentSpec] + ) -> Any: + """ + Get current component states. + + Called by MQTTBroker when Symphony requests component info. + This method returns the current state of deployed components. + + Args: + metadata: Request metadata. + components: List of components to query. + + Returns: + List of component data dictionaries. + """ + self.logger.info( + f"Symphony get: retrieving state for {len(components)} components" + ) + + # Return the requested components or empty list if none specified + # In a real implementation, this would query the actual system state + if not components: + current_components = [] + self.logger.info( + "No specific components requested, returning empty list" + ) + else: + # Return the requested components as-is for now + # In real implementation, query actual deployment status + current_components = components + self.logger.info( + f"Returning {len(current_components)} requested components" + ) + + return [to_dict(comp) for comp in current_components] + + def needs_update( + self, + metadata: Dict[str, Any], + pack: ComparisonPack + ) -> bool: + """ + Check if components need updates by comparing desired vs current state. + + Args: + metadata: Request metadata. + pack: Comparison pack containing desired and current components. + + Returns: + True if updates are needed, False otherwise. + """ + self.logger.info( + f"Checking update need for {len(pack.desired)} desired vs " + f"{len(pack.current)} current" + ) + + # Create lookup for current components + current_by_name = {comp.name: comp for comp in pack.current} + + for desired in pack.desired: + current = current_by_name.get(desired.name) + + if not current: + # Component doesn't exist, needs deployment + self.logger.info( + f"Component {desired.name} not found - needs deployment" + ) + return True + + # Check if component properties have changed + if self._component_changed(desired, current): + self.logger.info(f"Component {desired.name} has changes - needs update") + return True + + # No updates needed + self.logger.info("No components need updates") + return False + + def needs_remove(self, metadata: Dict[str, Any], pack: ComparisonPack) -> bool: + """Check if components need removal by comparing desired vs current state.""" + self.logger.info(f"Checking removal need for {len(pack.desired)} desired vs {len(pack.current)} current") + + # Create lookup for desired components + desired_by_name = {comp.name: comp for comp in pack.desired} + + for current in pack.current: + if current.name not in desired_by_name: + # Current component not in desired state, needs removal + self.logger.info(f"Component {current.name} not desired - needs removal") + return True + + # No removals needed + self.logger.info("No components need removal") + return False + + def _component_changed(self, desired: ComponentSpec, current: ComponentSpec) -> bool: + """Check if a component has changed between desired and current state.""" + # Compare key properties that would require updates + + # Type change + if desired.type != current.type: + return True + + # Properties change + if desired.properties != current.properties: + return True + + # Parameters change + if desired.parameters != current.parameters: + return True + + # Constraints change + if desired.constraints != current.constraints: + return True + + # Dependencies change + if desired.dependencies != current.dependencies: + return True + + return False + + def start(self) -> bool: + """Start the Symphony provider.""" + if not self._config.symphony.enabled: + self.logger.info("Symphony provider not enabled in configuration") + return False + + if self._running: + self.logger.warning("Symphony provider already running") + return True + + try: + # The MQTT broker is initialized in __init__ if enabled + # No additional startup steps needed + self._running = True + self.logger.info("Muto Symphony Provider started successfully") + return True + + except Exception as e: + self.logger.error(f"Failed to start Symphony provider: {e}") + return False + + def _do_cleanup(self) -> None: + """Stop the Symphony provider.""" + if not self._running: + return # Already cleaned up + + self._running = False + self.logger.info("Stopping Muto Symphony Provider") + + # Set shutdown event first to signal other threads + self._shutdown_event.set() + + try: + # Unregister from Symphony + self.unregister_target() + except Exception as e: + self.logger.error(f"Error during Symphony unregistration: {e}") + + try: + # Stop MQTT broker + if self._mqtt_broker: + self._mqtt_broker.stop() + self._mqtt_broker = None + except Exception as e: + self.logger.error(f"Error stopping MQTT broker: {e}") + + try: + # Close API client + if self._api_client: + self._api_client.close() + self._api_client = None + except Exception as e: + self.logger.error(f"Error closing API client: {e}") + + try: + # Destroy ROS2 services and publishers + if hasattr(self, '_register_service') and self._register_service: + self.destroy_service(self._register_service) + if hasattr(self, '_unregister_service') and self._unregister_service: + self.destroy_service(self._unregister_service) + if hasattr(self, 'stack_publisher') and self.stack_publisher: + self.destroy_publisher(self.stack_publisher) + except Exception as e: + self.logger.error(f"Error destroying ROS2 resources: {e}") + + self.logger.info("Muto Symphony Provider stopped") + + def is_running(self) -> bool: + """Check if the provider is running.""" + return self._running and not self._shutdown_event.is_set() + + def get_target_name(self) -> str: + """Get the Symphony target name.""" + return self._config.symphony.target + + def get_component_count(self) -> int: + """Get the number of managed components.""" + # Component tracking removed - return 0 for now + # In a real implementation, this would query the actual system + return 0 + + + + +def main(): + """Main entry point for the Symphony Provider.""" + provider = None + shutdown_requested = threading.Event() + + def signal_handler(signum, frame): + """Handle shutdown signals gracefully.""" + print(f"Received signal {signum}, initiating graceful shutdown...") + shutdown_requested.set() + if provider is not None: + provider._shutdown_event.set() + + # Register signal handlers + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + try: + rclpy.init() + provider = MutoSymphonyProvider() + provider.initialize() + + provider.get_logger().info("Symphony Provider started successfully") + + # Custom spin loop to handle shutdown gracefully + while rclpy.ok() and not shutdown_requested.is_set(): + try: + rclpy.spin_once(provider, timeout_sec=1.0) + except KeyboardInterrupt: + break + + except KeyboardInterrupt: + print("Symphony Provider interrupted by user") + except Exception as e: + print(f"Failed to start Symphony Provider: {e}") + + finally: + # Cleanup provider if it was created + if provider is not None: + try: + print("Cleaning up Symphony Provider...") + provider.cleanup() + except Exception as e: + print(f"Error during provider cleanup: {e}") + + # Only shutdown ROS2 if it's still initialized and we haven't already shut it down + try: + if rclpy.ok(): + print("Shutting down ROS2...") + rclpy.shutdown() + except Exception as e: + print(f"Error during ROS2 shutdown (this may be normal): {e}") + +if __name__ == '__main__': + exit(main()) \ No newline at end of file diff --git a/config/define-instance.sh b/config/define-instance.sh new file mode 100755 index 0000000..52d7c17 --- /dev/null +++ b/config/define-instance.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +export SYMPHONY_API_URL=http://192.168.0.47:8082/v1alpha2/ + +TOKEN=$(curl -X POST -H "Content-Type: application/json" -d '{"username":"admin","password":""}' "${SYMPHONY_API_URL}users/auth" | jq -r '.accessToken') + + +# Prompt user to press Enter to continue after the target has been registered + +curl -v -s -X GET -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" "${SYMPHONY_API_URL}instances" + +# Read the content of solution.json file and send it as data in the POST request +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SOLUTION_DATA=$(cat "$SCRIPT_DIR/instance.json") + +curl -v -s -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d "$SOLUTION_DATA" "${SYMPHONY_API_URL}instances/test-robot-debug-instance" + diff --git a/config/define-solution.sh b/config/define-solution.sh new file mode 100755 index 0000000..83c0db8 --- /dev/null +++ b/config/define-solution.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +# Script to create and deploy solution with base64 encoded stack data +# Usage: ./define-solution.sh + +# Check if JSON file argument is provided +if [ $# -eq 0 ]; then + echo "Usage: $0 " + echo "Example: $0 talker-listener-stack.json" + exit 1 +fi + +JSON_FILE="$1" + +# Check if file exists +if [ ! -f "$JSON_FILE" ]; then + echo "Error: File '$JSON_FILE' not found!" + exit 1 +fi + +# Extract solution name from filename (remove path and .json extension) +ROOT_NAME=$(basename "$JSON_FILE" .json) +SOLUTION_NAME="${ROOT_NAME}-v-1" + +export SYMPHONY_API_URL=http://192.168.0.47:8082/v1alpha2/ + +# Get authentication token +TOKEN=$(curl -X POST -H "Content-Type: application/json" -d '{"username":"admin","password":""}' "${SYMPHONY_API_URL}users/auth" | jq -r '.accessToken') + +# Base64 encode the contents of the JSON file +STACK_DATA_BASE64=$(base64 -w 0 "$JSON_FILE") + +# Create the solution JSON in a variable +SOLUTION_DATA=$(cat << EOF +{ + "metadata": { + "namespace": "default", + "name": "$SOLUTION_NAME" + }, + "spec": { + "displayName": "$SOLUTION_NAME", + "rootResource": "$ROOT_NAME", + "version": "1", + "components": [ + { + "name": "$SOLUTION_NAME", + "type": "muto-agent", + "properties": { + "type": "stack", + "content-type": "application/json", + "data": "$STACK_DATA_BASE64", + "foo": "bar", + "number": 123 + } + } + ] + } +} +EOF +) + +echo "Created solution JSON with base64 encoded stack data" +echo "Base64 encoded data length: ${#STACK_DATA_BASE64} characters" + +# Show current solutions +# echo "Current solutions:" +# curl -s -X GET -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" "${SYMPHONY_API_URL}solutions" | jq . + +echo "Posting $SOLUTION_NAME solution to Symphony..." + +# Try to delete existing solution first (ignore errors if it doesn't exist) +# curl -s -X DELETE -H "Authorization: Bearer $TOKEN" "${SYMPHONY_API_URL}solutions/$SOLUTION_NAME" > /dev/null 2>&1 + +# Post the new solution +curl -s -v -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d "$SOLUTION_DATA" "${SYMPHONY_API_URL}solutions/$SOLUTION_NAME" diff --git a/config/delete_muto_provider.sh b/config/delete_muto_provider.sh new file mode 100755 index 0000000..2b00c5e --- /dev/null +++ b/config/delete_muto_provider.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +export SYMPHONY_API_URL=http://192.168.0.47:8082/v1alpha2/ + +TOKEN=$(curl -X POST -H "Content-Type: application/json" -d '{"username":"admin","password":""}' "${SYMPHONY_API_URL}users/auth" | jq -r '.accessToken') +# Prompt user to press Enter to continue after the target has been registered + +curl -v -s -X DELETE -H "Authorization: Bearer $TOKEN" "${SYMPHONY_API_URL}targets/registry/test-robot-debug" diff --git a/config/instance.json b/config/instance.json new file mode 100644 index 0000000..54e3ba8 --- /dev/null +++ b/config/instance.json @@ -0,0 +1,14 @@ +{ + "metadata": { + "name": "test-robot-debug-instance", + "labels": { + "muto": "demo" + } + }, + "spec": { + "solution": "talker-listener:1", + "target": { + "name": "test-robot-debug" + } + } +} \ No newline at end of file diff --git a/config/solution.json b/config/solution.json new file mode 100644 index 0000000..5bc63c2 --- /dev/null +++ b/config/solution.json @@ -0,0 +1,22 @@ +{ + "metadata": { + "namespace": "default", + "name": "talker-listener-v-1" + }, + "spec": { + "displayName": "talker-listener-v-1", + "rootResource": "talker-listener", + "version": "1", + "components": [ + { + "name": "talker-listener-stack", + "type": "muto-agent", + "properties": { + "type": "stack", + "content-type": "json", + "data": "jajaja" + } + } + ] + } +} \ No newline at end of file diff --git a/config/talker-listener-solution.json b/config/talker-listener-solution.json new file mode 100644 index 0000000..69d4e51 --- /dev/null +++ b/config/talker-listener-solution.json @@ -0,0 +1,31 @@ +{ + "metadata": { + "namespace": "default", + "name": "talker-listener-v-2" + }, + "spec": { + "displayName": "talker-listener-v-2", + "rootResource": "talker-listener", + "version": "2", + "components": [ + { + "name": "talker-listener-stack", + "type": "muto-agent", + "properties": { + "type": "stack", + "content-type": "json", + "data": "ewogICJuYW1lIjogIk11dG8gU2ltcGxlIFRhbGtlci1MaXN0ZW5lciBTdGFjayIsCiAgImNvbnRleHQiOiAiZXRlcmF0aW9uX29mZmljZSIsCiAgInN0YWNrSWQiOiAib3JnLmVjbGlwc2UubXV0by5zYW5kYm94OnRhbGtlcl9saXN0ZW5lciIsCiAgIm5vZGUiOiBbCiAgICB7CiAgICAgICJuYW1lIjogInRhbGtlciIsCiAgICAgICJwa2ciOiAiZGVtb19ub2Rlc19jcHAiLAogICAgICAiZXhlYyI6ICJ0YWxrZXIiCiAgICB9LAogICAgewogICAgICAibmFtZSI6ICJsaXN0ZW5lciIsCiAgICAgICJwa2ciOiAiZGVtb19ub2Rlc19jcHAiLAogICAgICAiZXhlYyI6ICJsaXN0ZW5lciIKICAgIH0KICBdCn0=" + } + }, + { + "name": "talker-listener-stack2", + "type": "muto-agent", + "properties": { + "type": "stack", + "content-type": "json", + "data": "ewogICJuYW1lIjogIk11dG8gU2ltcGxlIFRhbGtlci1MaXN0ZW5lciBTdGFjayIsCiAgImNvbnRleHQiOiAiZXRlcmF0aW9uX29mZmljZSIsCiAgInN0YWNrSWQiOiAib3JnLmVjbGlwc2UubXV0by5zYW5kYm94OnRhbGtlcl9saXN0ZW5lciIsCiAgIm5vZGUiOiBbCiAgICB7CiAgICAgICJuYW1lIjogInRhbGtlciIsCiAgICAgICJwa2ciOiAiZGVtb19ub2Rlc19jcHAiLAogICAgICAiZXhlYyI6ICJ0YWxrZXIiCiAgICB9LAogICAgewogICAgICAibmFtZSI6ICJsaXN0ZW5lciIsCiAgICAgICJwa2ciOiAiZGVtb19ub2Rlc19jcHAiLAogICAgICAiZXhlYyI6ICJsaXN0ZW5lciIKICAgIH0KICBdCn0=" + } + } + ] + } +} diff --git a/config/talker-listener.json b/config/talker-listener.json new file mode 100644 index 0000000..d047e45 --- /dev/null +++ b/config/talker-listener.json @@ -0,0 +1,17 @@ +{ + "name": "Muto Simple Talker-Listener Stack", + "context": "eteration_office", + "stackId": "org.eclipse.muto.sandbox:talker_listener", + "node": [ + { + "name": "talker", + "pkg": "demo_nodes_cpp", + "exec": "talker" + }, + { + "name": "listener", + "pkg": "demo_nodes_cpp", + "exec": "listener" + } + ] +} \ No newline at end of file diff --git a/config/target.json b/config/target.json new file mode 100644 index 0000000..f13d538 --- /dev/null +++ b/config/target.json @@ -0,0 +1,34 @@ +{ + "metadata": { + "name": "ankaios-target" + }, + "spec": { + "forceRedeploy": true, + "components": [ + { + "name": "muto", + "type": "muto-agent", + "properties": { + } + } + ], + "topologies": [ + { + "bindings": [ + { + "role": "muto", + "provider": "providers.target.mqtt", + "config": { + "name": "proxy", + "brokerAddress": "tcp://mosquitto:1883", + "clientID": "symphony", + "requestTopic": "coa-request", + "responseTopic": "coa-response", + "timeoutSeconds": "30" + } + } + ] + } + ] + } +} \ No newline at end of file diff --git a/setup.py b/setup.py index ccab28c..0bf6958 100755 --- a/setup.py +++ b/setup.py @@ -25,7 +25,8 @@ 'console_scripts': [ 'muto_agent = agent.muto_agent:main', 'mqtt = agent.mqtt:main', - 'commands = agent.commands:main' + 'commands = agent.commands:main', + 'symphony_provider = agent.symphony.symphony_provider:main' ], }, python_requires='>=3.8', diff --git a/test/test_symphony_api.py b/test/test_symphony_api.py new file mode 100644 index 0000000..87b0f14 --- /dev/null +++ b/test/test_symphony_api.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +Simplified unit tests for Symphony API client focusing on actual methods. +""" + +import unittest +import requests +from unittest.mock import Mock, patch +from agent.symphony.sdk.symphony_api import ( + SymphonyAPIClient, + SymphonyAPIError +) + + +class TestSymphonyAPIError(unittest.TestCase): + """Test cases for SymphonyAPIError exception.""" + + def test_symphony_api_error_creation(self): + """Test SymphonyAPIError creation.""" + error = SymphonyAPIError("Test error message") + self.assertEqual(str(error), "Test error message") + self.assertIsNone(error.status_code) + self.assertIsNone(error.response_text) + + def test_symphony_api_error_with_details(self): + """Test SymphonyAPIError creation with details.""" + error = SymphonyAPIError( + "API request failed", + status_code=404, + response_text="Not Found" + ) + self.assertEqual(str(error), "API request failed") + self.assertEqual(error.status_code, 404) + + + +class TestSymphonyAPIClient(unittest.TestCase): + """Test cases for SymphonyAPIClient.""" + + def setUp(self): + """Set up test fixtures.""" + self.base_url = "https://symphony.example.com" + self.username = "testuser" + self.password = "testpass" + self.client = SymphonyAPIClient( + base_url=self.base_url, + username=self.username, + password=self.password, + timeout=10.0 + ) + + def tearDown(self): + """Clean up after tests.""" + if self.client: + self.client.close() + + def test_client_initialization(self): + """Test SymphonyAPIClient initialization.""" + self.assertEqual(self.client.base_url, self.base_url) + self.assertEqual(self.client.username, self.username) + self.assertEqual(self.client.password, self.password) + self.assertEqual(self.client.timeout, 10.0) + + def test_client_context_manager(self): + """Test SymphonyAPIClient as context manager.""" + with SymphonyAPIClient(self.base_url, self.username, self.password) as client: + self.assertIsInstance(client, SymphonyAPIClient) + # Client should be closed after context exit + + @patch('requests.Session.request') + def test_make_request_basic_functionality(self, mock_request): + """Test basic request functionality.""" + # Mock successful response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.text = 'OK' + mock_request.return_value = mock_response + + response = self.client._make_request('GET', '/api/test') + + self.assertEqual(response.status_code, 200) + self.assertTrue(mock_request.called) + + @patch('requests.Session.request') + def test_make_request_timeout_handling(self, mock_request): + """Test request timeout handling.""" + mock_request.side_effect = requests.exceptions.Timeout("Request timed out") + + with self.assertRaises(SymphonyAPIError) as context: + self.client._make_request('GET', '/api/test') + + self.assertIn("request failed", str(context.exception).lower()) + + @patch.object(SymphonyAPIClient, '_handle_response') + @patch.object(SymphonyAPIClient, '_make_request') + def test_authenticate_basic(self, mock_make_request, mock_handle_response): + """Test basic authentication functionality.""" + # Mock successful auth response with correct key name + mock_response = Mock() + mock_make_request.return_value = mock_response + mock_handle_response.return_value = {"accessToken": "test_token"} + + token = self.client.authenticate() + + self.assertEqual(token, "test_token") + self.assertEqual(self.client._access_token, "test_token") + + @patch.object(SymphonyAPIClient, '_handle_response') + @patch.object(SymphonyAPIClient, '_make_request') + def test_register_target_basic(self, mock_make_request, mock_handle_response): + """Test basic target registration.""" + # Set up authentication + self.client._access_token = "test_token" + + mock_response = Mock() + mock_make_request.return_value = mock_response + mock_handle_response.return_value = {"status": "registered"} + + target_name = "test-target" + target_spec = {"type": "device", "location": "datacenter1"} + + result = self.client.register_target(target_name, target_spec) + + self.assertEqual(result, {"status": "registered"}) + self.assertTrue(mock_make_request.called) + + @patch.object(SymphonyAPIClient, '_handle_response') + @patch.object(SymphonyAPIClient, '_make_request') + def test_get_target_basic(self, mock_make_request, mock_handle_response): + """Test basic target retrieval.""" + # Set up authentication + self.client._access_token = "test_token" + + mock_response = Mock() + mock_make_request.return_value = mock_response + mock_handle_response.return_value = { + "name": "test-target", + "status": "active", + "spec": {"type": "device"} + } + + target_name = "test-target" + result = self.client.get_target(target_name) + + self.assertEqual(result["name"], target_name) + self.assertEqual(result["status"], "active") + + @patch.object(SymphonyAPIClient, '_handle_response') + @patch.object(SymphonyAPIClient, '_make_request') + def test_list_targets_basic(self, mock_make_request, mock_handle_response): + """Test basic target listing.""" + # Set up authentication + self.client._access_token = "test_token" + + mock_response = Mock() + mock_make_request.return_value = mock_response + mock_handle_response.return_value = { + "targets": [ + {"name": "target1", "status": "active"}, + {"name": "target2", "status": "inactive"} + ] + } + + result = self.client.list_targets() + + self.assertIn("targets", result) + self.assertEqual(len(result["targets"]), 2) + + @patch.object(SymphonyAPIClient, '_handle_response') + @patch.object(SymphonyAPIClient, '_make_request') + def test_unregister_target_basic(self, mock_make_request, mock_handle_response): + """Test basic target unregistration.""" + # Set up authentication + self.client._access_token = "test_token" + + mock_response = Mock() + mock_make_request.return_value = mock_response + mock_handle_response.return_value = {"status": "unregistered"} + + target_name = "test-target" + + result = self.client.unregister_target(target_name) + + self.assertEqual(result, {"status": "unregistered"}) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/test/test_symphony_sdk.py b/test/test_symphony_sdk.py new file mode 100644 index 0000000..5f024ec --- /dev/null +++ b/test/test_symphony_sdk.py @@ -0,0 +1,511 @@ +#!/usr/bin/env python3 +""" +Comprehensive unit tests for Symphony SDK core functionality. +""" + +import unittest +import json +import base64 +from unittest.mock import Mock, patch +from agent.symphony.sdk.symphony_sdk import ( + COARequest, + COAResponse, + ComponentSpec, + DeploymentSpec, + SolutionSpec, + SolutionState, + TargetSpec, + InstanceSpec, + SymphonyProvider, + to_dict, + from_dict, + serialize_components, + deserialize_components, + deserialize_deployment, + serialize_coa_request, + deserialize_coa_request, + serialize_coa_response, + deserialize_coa_response, + ObjectMeta +) +from agent.symphony.sdk.symphony_types import State + + +class TestObjectMeta(unittest.TestCase): + """Test cases for ObjectMeta dataclass.""" + + def test_object_meta_creation(self): + """Test ObjectMeta creation with default values.""" + meta = ObjectMeta() + self.assertEqual(meta.name, "") + self.assertEqual(meta.namespace, "") + self.assertIsNone(meta.labels) + self.assertIsNone(meta.annotations) + + def test_object_meta_with_values(self): + """Test ObjectMeta creation with specific values.""" + labels = {"app": "test", "version": "1.0"} + annotations = {"description": "test object"} + meta = ObjectMeta( + name="test-object", + namespace="test-namespace", + labels=labels, + annotations=annotations + ) + self.assertEqual(meta.name, "test-object") + self.assertEqual(meta.namespace, "test-namespace") + self.assertEqual(meta.labels, labels) + self.assertEqual(meta.annotations, annotations) + + +class TestComponentSpec(unittest.TestCase): + """Test cases for ComponentSpec dataclass.""" + + def test_component_spec_creation(self): + """Test ComponentSpec creation with defaults.""" + comp = ComponentSpec() + self.assertEqual(comp.name, "") + self.assertEqual(comp.type, "") + self.assertIsNone(comp.routes) + self.assertEqual(comp.constraints, "") + self.assertIsNone(comp.properties) + + def test_component_spec_with_values(self): + """Test ComponentSpec creation with specific values.""" + properties = {"key1": "value1", "key2": "value2"} + comp = ComponentSpec( + name="test-component", + type="service", + constraints="cpu=2", + properties=properties + ) + + self.assertEqual(comp.name, "test-component") + self.assertEqual(comp.type, "service") + self.assertEqual(comp.constraints, "cpu=2") + self.assertEqual(comp.properties, properties) + + +class TestDeploymentSpec(unittest.TestCase): + """Test cases for DeploymentSpec dataclass.""" + + def test_deployment_spec_creation(self): + """Test DeploymentSpec creation with defaults.""" + deployment = DeploymentSpec() + self.assertEqual(deployment.solutionName, "") + self.assertIsNone(deployment.solution) + self.assertIsNone(deployment.instance) + self.assertEqual(deployment.activeTarget, "") + + def test_deployment_spec_get_components_slice_no_solution(self): + """Test get_components_slice with no solution.""" + deployment = DeploymentSpec() + components = deployment.get_components_slice() + self.assertEqual(components, []) + + def test_deployment_spec_get_components_slice_with_indices(self): + """Test get_components_slice with start and end indices.""" + # Create components + comp1 = ComponentSpec(name="comp1") + comp2 = ComponentSpec(name="comp2") + comp3 = ComponentSpec(name="comp3") + + # Create solution with components + solution_spec = SolutionSpec(components=[comp1, comp2, comp3]) + solution_state = SolutionState(spec=solution_spec) + + deployment = DeploymentSpec( + solution=solution_state, + componentStartIndex=1, + componentEndIndex=3 + ) + + components = deployment.get_components_slice() + self.assertEqual(len(components), 2) + self.assertEqual(components[0].name, "comp2") + self.assertEqual(components[1].name, "comp3") + + def test_deployment_spec_get_components_slice_all_components(self): + """Test get_components_slice returning all components.""" + comp1 = ComponentSpec(name="comp1") + comp2 = ComponentSpec(name="comp2") + + solution_spec = SolutionSpec(components=[comp1, comp2]) + solution_state = SolutionState(spec=solution_spec) + + deployment = DeploymentSpec(solution=solution_state) + + components = deployment.get_components_slice() + self.assertEqual(len(components), 2) + self.assertEqual(components[0].name, "comp1") + self.assertEqual(components[1].name, "comp2") + + +class TestCOABodyMixin(unittest.TestCase): + """Test cases for COABodyMixin functionality.""" + + def test_coa_request_set_json_body(self): + """Test COARequest set_body with JSON data.""" + request = COARequest() + data = {"key": "value", "number": 123} + + request.set_body(data, "application/json") + + self.assertEqual(request.content_type, "application/json") + + # Decode and verify + decoded_body = json.loads(base64.b64decode(request.body).decode('utf-8')) + self.assertEqual(decoded_body, data) + + def test_coa_request_set_text_body(self): + """Test COARequest set_body with text data.""" + request = COARequest() + text_data = "Hello, World!" + + request.set_body(text_data, "text/plain") + + self.assertEqual(request.content_type, "text/plain") + self.assertEqual(request.body, text_data) + + def test_coa_request_set_binary_body(self): + """Test COARequest set_body with binary data.""" + request = COARequest() + binary_data = b"Binary data content" + + request.set_body(binary_data, "application/octet-stream") + + self.assertEqual(request.content_type, "application/octet-stream") + + # Decode and verify + decoded_body = base64.b64decode(request.body) + self.assertEqual(decoded_body, binary_data) + + def test_coa_request_get_json_body(self): + """Test COARequest get_body with JSON data.""" + request = COARequest() + data = {"test": "data", "array": [1, 2, 3]} + + request.set_body(data, "application/json") + retrieved_data = request.get_body() + + self.assertEqual(retrieved_data, data) + + def test_coa_request_get_text_body(self): + """Test COARequest get_body with text data.""" + request = COARequest() + text_data = "Simple text content" + + request.set_body(text_data, "text/plain") + retrieved_data = request.get_body() + + self.assertEqual(retrieved_data, text_data) + + def test_coa_request_get_binary_body(self): + """Test COARequest get_body with binary data.""" + request = COARequest() + binary_data = b"Binary content for testing" + + request.set_body(binary_data, "application/octet-stream") + retrieved_data = request.get_body() + + self.assertEqual(retrieved_data, binary_data) + + +class TestCOARequest(unittest.TestCase): + """Test cases for COARequest dataclass.""" + + def test_coa_request_creation(self): + """Test COARequest creation with defaults.""" + request = COARequest() + self.assertEqual(request.method, "GET") + self.assertEqual(request.route, "") + self.assertEqual(request.content_type, "application/json") + self.assertEqual(request.body, "") + + def test_coa_request_with_values(self): + """Test COARequest creation with specific values.""" + metadata = {"version": "1.0"} + parameters = {"param1": "value1"} + + request = COARequest( + method="POST", + route="/api/v1/deploy", + metadata=metadata, + parameters=parameters + ) + + self.assertEqual(request.method, "POST") + self.assertEqual(request.route, "/api/v1/deploy") + self.assertEqual(request.metadata, metadata) + self.assertEqual(request.parameters, parameters) + + def test_coa_request_to_json_dict(self): + """Test COARequest to_json_dict conversion.""" + request = COARequest( + method="PUT", + route="/test", + metadata={"key": "value"}, + parameters={"param": "test"} + ) + request.set_body({"data": "test"}) + + json_dict = request.to_json_dict() + + expected_keys = ["method", "route", "content-type", "body", "metadata", "parameters"] + for key in expected_keys: + self.assertIn(key, json_dict) + + self.assertEqual(json_dict["method"], "PUT") + self.assertEqual(json_dict["route"], "/test") + self.assertEqual(json_dict["content-type"], "application/json") + + +class TestCOAResponse(unittest.TestCase): + """Test cases for COAResponse dataclass.""" + + def test_coa_response_creation(self): + """Test COAResponse creation with defaults.""" + response = COAResponse() + self.assertEqual(response.state, State.OK) + self.assertEqual(response.content_type, "application/json") + self.assertEqual(response.body, "") + + def test_coa_response_success_factory(self): + """Test COAResponse.success factory method.""" + data = {"result": "success", "count": 5} + response = COAResponse.success(data) + + self.assertEqual(response.state, State.OK) + self.assertEqual(response.content_type, "application/json") + + retrieved_data = response.get_body() + self.assertEqual(retrieved_data, data) + + def test_coa_response_error_factory(self): + """Test COAResponse.error factory method.""" + error_msg = "Something went wrong" + response = COAResponse.error(error_msg, State.INTERNAL_ERROR) + + self.assertEqual(response.state, State.INTERNAL_ERROR) + retrieved_data = response.get_body() + self.assertEqual(retrieved_data["error"], error_msg) + + def test_coa_response_not_found_factory(self): + """Test COAResponse.not_found factory method.""" + response = COAResponse.not_found("Resource not found") + + self.assertEqual(response.state, State.NOT_FOUND) + retrieved_data = response.get_body() + self.assertEqual(retrieved_data["error"], "Resource not found") + + def test_coa_response_bad_request_factory(self): + """Test COAResponse.bad_request factory method.""" + response = COAResponse.bad_request("Invalid input") + + self.assertEqual(response.state, State.BAD_REQUEST) + retrieved_data = response.get_body() + self.assertEqual(retrieved_data["error"], "Invalid input") + + def test_coa_response_to_json_dict(self): + """Test COAResponse to_json_dict conversion.""" + response = COAResponse( + state=State.OK, + metadata={"version": "1.0"}, + redirect_uri="https://example.com/redirect" + ) + response.set_body({"status": "ok"}) + + json_dict = response.to_json_dict() + + expected_keys = ["content-type", "body", "state", "metadata", "redirectUri"] + for key in expected_keys: + self.assertIn(key, json_dict) + + self.assertEqual(json_dict["state"], State.OK.value) + self.assertEqual(json_dict["redirectUri"], "https://example.com/redirect") + + +class TestUtilityFunctions(unittest.TestCase): + """Test cases for utility functions.""" + + def test_to_dict_with_simple_object(self): + """Test to_dict with simple dataclass object.""" + comp = ComponentSpec(name="test", type="service") + result = to_dict(comp) + + self.assertIsInstance(result, dict) + self.assertEqual(result["name"], "test") + self.assertEqual(result["type"], "service") + + def test_to_dict_with_none(self): + """Test to_dict with None input.""" + result = to_dict(None) + self.assertEqual(result, {}) + + def test_to_dict_with_nested_objects(self): + """Test to_dict with nested dataclass objects.""" + meta = ObjectMeta(name="test-meta") + comp = ComponentSpec(name="test-comp") + solution_spec = SolutionSpec(components=[comp]) + solution_state = SolutionState(metadata=meta, spec=solution_spec) + + result = to_dict(solution_state) + + self.assertIsInstance(result, dict) + self.assertIn("metadata", result) + self.assertIn("spec", result) + self.assertEqual(result["metadata"]["name"], "test-meta") + + def test_serialize_components(self): + """Test serialize_components function.""" + comp1 = ComponentSpec(name="comp1", type="service") + comp2 = ComponentSpec(name="comp2", type="deployment") + components = [comp1, comp2] + + json_str = serialize_components(components) + + self.assertIsInstance(json_str, str) + + # Verify JSON is valid + data = json.loads(json_str) + self.assertEqual(len(data), 2) + self.assertEqual(data[0]["name"], "comp1") + self.assertEqual(data[1]["name"], "comp2") + + def test_deserialize_components(self): + """Test deserialize_components function.""" + json_data = [ + {"name": "test-comp1", "type": "service"}, + {"name": "test-comp2", "type": "deployment"} + ] + json_str = json.dumps(json_data) + + components = deserialize_components(json_str) + + self.assertEqual(len(components), 2) + self.assertEqual(components[0].name, "test-comp1") + self.assertEqual(components[0].type, "service") + self.assertEqual(components[1].name, "test-comp2") + self.assertEqual(components[1].type, "deployment") + + def test_deserialize_components_invalid_json(self): + """Test deserialize_components with invalid JSON.""" + invalid_json = "{ invalid json }" + components = deserialize_components(invalid_json) + self.assertEqual(components, []) + + def test_deserialize_deployment(self): + """Test deserialize_deployment function.""" + deployment_data = { + "solutionName": "test-solution", + "activeTarget": "test-target" + } + json_str = json.dumps(deployment_data) + + deployments = deserialize_deployment(json_str) + + self.assertEqual(len(deployments), 1) + self.assertEqual(deployments[0].solutionName, "test-solution") + self.assertEqual(deployments[0].activeTarget, "test-target") + + def test_serialize_coa_request(self): + """Test serialize_coa_request function.""" + request = COARequest( + method="POST", + route="/test", + metadata={"key": "value"} + ) + request.set_body({"data": "test"}) + + json_str = serialize_coa_request(request) + + self.assertIsInstance(json_str, str) + + # Verify JSON is valid + data = json.loads(json_str) + self.assertEqual(data["method"], "POST") + self.assertEqual(data["route"], "/test") + + def test_deserialize_coa_request(self): + """Test deserialize_coa_request function.""" + request_data = { + "method": "PUT", + "route": "/api/test", + "content-type": "application/json", + "body": base64.b64encode(json.dumps({"test": "data"}).encode()).decode(), + "metadata": {"version": "1.0"} + } + json_str = json.dumps(request_data) + + request = deserialize_coa_request(json_str) + + self.assertEqual(request.method, "PUT") + self.assertEqual(request.route, "/api/test") + self.assertEqual(request.content_type, "application/json") + self.assertEqual(request.metadata, {"version": "1.0"}) + + def test_serialize_coa_response(self): + """Test serialize_coa_response function.""" + response = COAResponse.success({"result": "ok"}) + + json_str = serialize_coa_response(response) + + self.assertIsInstance(json_str, str) + + # Verify JSON is valid + data = json.loads(json_str) + self.assertEqual(data["state"], State.OK.value) + + def test_deserialize_coa_response(self): + """Test deserialize_coa_response function.""" + response_data = { + "content-type": "application/json", + "body": base64.b64encode(json.dumps({"status": "success"}).encode()).decode(), + "state": State.ACCEPTED.value, + "metadata": {"processed": "true"} + } + json_str = json.dumps(response_data) + + response = deserialize_coa_response(json_str) + + self.assertEqual(response.state, State.ACCEPTED) + self.assertEqual(response.content_type, "application/json") + self.assertEqual(response.metadata, {"processed": "true"}) + + +class TestSymphonyProvider(unittest.TestCase): + """Test cases for SymphonyProvider abstract class.""" + + def test_symphony_provider_interface(self): + """Test that SymphonyProvider defines required interface methods.""" + # Test that abstract methods exist + self.assertTrue(hasattr(SymphonyProvider, 'init_provider')) + self.assertTrue(hasattr(SymphonyProvider, 'apply')) + self.assertTrue(hasattr(SymphonyProvider, 'remove')) + self.assertTrue(hasattr(SymphonyProvider, 'get')) + + # Test that methods are callable + self.assertTrue(callable(getattr(SymphonyProvider, 'init_provider'))) + self.assertTrue(callable(getattr(SymphonyProvider, 'apply'))) + self.assertTrue(callable(getattr(SymphonyProvider, 'remove'))) + self.assertTrue(callable(getattr(SymphonyProvider, 'get'))) + + def test_symphony_provider_not_implemented_errors(self): + """Test that SymphonyProvider methods raise NotImplementedError.""" + provider = SymphonyProvider() + + with self.assertRaises(NotImplementedError): + provider.init_provider() + + with self.assertRaises(NotImplementedError): + provider.apply({}, []) + + with self.assertRaises(NotImplementedError): + provider.remove({}, []) + + with self.assertRaises(NotImplementedError): + provider.get({}, []) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/test/test_symphony_summary.py b/test/test_symphony_summary.py new file mode 100644 index 0000000..486d110 --- /dev/null +++ b/test/test_symphony_summary.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +""" +Comprehensive unit tests for Symphony SDK summary models. +""" + +import unittest +import json +from agent.symphony.sdk.symphony_summary import ( + SummaryState, + ComponentResultSpec, + TargetResultSpec, + SummaryResult, + SummarySpec, + create_success_component_result, + create_failed_component_result, + create_target_result +) +from agent.symphony.sdk.symphony_types import State + + +class TestSummaryState(unittest.TestCase): + """Test cases for SummaryState enumeration.""" + + def test_summary_state_values(self): + """Test SummaryState enum values.""" + self.assertEqual(SummaryState.PENDING, 0) + self.assertEqual(SummaryState.RUNNING, 1) + self.assertEqual(SummaryState.DONE, 2) + + def test_summary_state_from_int(self): + """Test creating SummaryState from integer.""" + self.assertEqual(SummaryState(0), SummaryState.PENDING) + self.assertEqual(SummaryState(1), SummaryState.RUNNING) + self.assertEqual(SummaryState(2), SummaryState.DONE) + + +class TestComponentResultSpec(unittest.TestCase): + """Test cases for ComponentResultSpec.""" + + def test_component_result_spec_creation(self): + """Test ComponentResultSpec creation with default values.""" + result = ComponentResultSpec() + self.assertEqual(result.status, State.OK) + self.assertEqual(result.message, "") + + def test_component_result_spec_with_values(self): + """Test ComponentResultSpec creation with specific values.""" + result = ComponentResultSpec( + status=State.UPDATE_FAILED, + message="Update operation failed" + ) + self.assertEqual(result.status, State.UPDATE_FAILED) + self.assertEqual(result.message, "Update operation failed") + + def test_component_result_spec_to_dict(self): + """Test ComponentResultSpec to_dict conversion.""" + result = ComponentResultSpec( + status=State.UPDATED, + message="Component updated successfully" + ) + + expected_dict = { + "status": State.UPDATED.value, + "message": "Component updated successfully" + } + + self.assertEqual(result.to_dict(), expected_dict) + + def test_component_result_spec_from_dict(self): + """Test ComponentResultSpec from_dict conversion.""" + data = { + "status": State.DELETE_FAILED.value, + "message": "Failed to delete component" + } + + result = ComponentResultSpec.from_dict(data) + self.assertEqual(result.status, State.DELETE_FAILED) + self.assertEqual(result.message, "Failed to delete component") + + def test_component_result_spec_from_dict_defaults(self): + """Test ComponentResultSpec from_dict with missing values.""" + data = {} + result = ComponentResultSpec.from_dict(data) + self.assertEqual(result.status, State.OK) + self.assertEqual(result.message, "") + + def test_component_result_spec_from_dict_partial(self): + """Test ComponentResultSpec from_dict with partial data.""" + data = {"status": State.UPDATED.value} + result = ComponentResultSpec.from_dict(data) + self.assertEqual(result.status, State.UPDATED) + self.assertEqual(result.message, "") + + +class TestTargetResultSpec(unittest.TestCase): + """Test cases for TargetResultSpec.""" + + def test_target_result_spec_creation(self): + """Test TargetResultSpec creation with defaults.""" + result = TargetResultSpec() + self.assertEqual(result.status, "OK") + self.assertEqual(result.message, "") + self.assertEqual(result.component_results, {}) + + def test_target_result_spec_with_values(self): + """Test TargetResultSpec creation with specific values.""" + comp1 = ComponentResultSpec(State.UPDATED, "Component 1 updated") + comp2 = ComponentResultSpec(State.UPDATE_FAILED, "Component 2 failed") + + result = TargetResultSpec( + status="PARTIAL_SUCCESS", + message="Some components failed", + component_results={ + "comp1": comp1, + "comp2": comp2 + } + ) + + self.assertEqual(result.status, "PARTIAL_SUCCESS") + self.assertEqual(result.message, "Some components failed") + self.assertEqual(len(result.component_results), 2) + self.assertEqual(result.component_results["comp1"], comp1) + self.assertEqual(result.component_results["comp2"], comp2) + + def test_target_result_spec_to_dict(self): + """Test TargetResultSpec to_dict conversion.""" + comp_result = ComponentResultSpec(State.UPDATED, "Success") + result = TargetResultSpec( + status="SUCCESS", + message="All components updated", + component_results={"comp1": comp_result} + ) + + result_dict = result.to_dict() + + expected_dict = { + "status": "SUCCESS", + "message": "All components updated", + "components": { + "comp1": { + "status": State.UPDATED.value, + "message": "Success" + } + } + } + + self.assertEqual(result_dict, expected_dict) + + def test_target_result_spec_to_dict_minimal(self): + """Test TargetResultSpec to_dict with minimal data.""" + result = TargetResultSpec(status="OK") + result_dict = result.to_dict() + + expected_dict = {"status": "OK"} + self.assertEqual(result_dict, expected_dict) + + def test_target_result_spec_from_dict(self): + """Test TargetResultSpec from_dict conversion.""" + data = { + "status": "FAILED", + "message": "Operation failed", + "components": { + "comp1": { + "status": State.UPDATE_FAILED.value, + "message": "Component update failed" + } + } + } + + result = TargetResultSpec.from_dict(data) + + self.assertEqual(result.status, "FAILED") + self.assertEqual(result.message, "Operation failed") + self.assertEqual(len(result.component_results), 1) + + comp_result = result.component_results["comp1"] + self.assertEqual(comp_result.status, State.UPDATE_FAILED) + self.assertEqual(comp_result.message, "Component update failed") + + def test_target_result_spec_from_dict_no_components(self): + """Test TargetResultSpec from_dict without components.""" + data = { + "status": "SUCCESS", + "message": "No components to process" + } + + result = TargetResultSpec.from_dict(data) + self.assertEqual(result.status, "SUCCESS") + self.assertEqual(result.message, "No components to process") + self.assertEqual(result.component_results, {}) + + +class TestSummaryResult(unittest.TestCase): + """Test cases for SummaryResult.""" + + def test_summary_result_creation(self): + """Test SummaryResult creation.""" + summary_spec = SummarySpec() + summary = SummaryResult( + summary=summary_spec, + state=SummaryState.DONE, + generation="1" + ) + + self.assertEqual(summary.state, SummaryState.DONE) + self.assertEqual(summary.generation, "1") + self.assertEqual(summary.summary, summary_spec) + + def test_summary_result_is_deployment_finished(self): + """Test SummaryResult is_deployment_finished method.""" + # Test with DONE state + summary_done = SummaryResult(state=SummaryState.DONE) + self.assertTrue(summary_done.is_deployment_finished()) + + # Test with RUNNING state + summary_running = SummaryResult(state=SummaryState.RUNNING) + self.assertFalse(summary_running.is_deployment_finished()) + + +class TestSummarySpec(unittest.TestCase): + """Test cases for SummarySpec.""" + + def test_summary_spec_creation(self): + """Test SummarySpec creation.""" + spec = SummarySpec( + target_count=2, + success_count=1 + ) + + self.assertEqual(spec.target_count, 2) + self.assertEqual(spec.success_count, 1) + + def test_summary_spec_defaults(self): + """Test SummarySpec with default values.""" + spec = SummarySpec() + self.assertEqual(spec.target_count, 0) + self.assertEqual(spec.success_count, 0) + + +class TestHelperFunctions(unittest.TestCase): + """Test cases for helper functions.""" + + def test_create_success_component_result(self): + """Test create_success_component_result function.""" + result = create_success_component_result("Operation completed") + + self.assertEqual(result.status, State.OK) + self.assertEqual(result.message, "Operation completed") + + def test_create_success_component_result_default_message(self): + """Test create_success_component_result with default message.""" + result = create_success_component_result() + + self.assertEqual(result.status, State.OK) + self.assertEqual(result.message, "") + + def test_create_failed_component_result(self): + """Test create_failed_component_result function.""" + result = create_failed_component_result("Operation failed", State.UPDATE_FAILED) + + self.assertEqual(result.status, State.UPDATE_FAILED) + self.assertEqual(result.message, "Operation failed") + + def test_create_failed_component_result_default_state(self): + """Test create_failed_component_result with default state.""" + result = create_failed_component_result("Something went wrong") + + self.assertEqual(result.status, State.INTERNAL_ERROR) + self.assertEqual(result.message, "Something went wrong") + + def test_create_target_result(self): + """Test create_target_result function.""" + components = { + "comp1": create_success_component_result("Updated"), + "comp2": create_failed_component_result("Failed") + } + + result = create_target_result("PARTIAL", "Some components failed", components) + + self.assertEqual(result.status, "PARTIAL") + self.assertEqual(result.message, "Some components failed") + self.assertEqual(len(result.component_results), 2) + self.assertEqual(result.component_results["comp1"].status, State.OK) + self.assertEqual(result.component_results["comp2"].status, State.INTERNAL_ERROR) + + def test_create_target_result_minimal(self): + """Test create_target_result with minimal parameters.""" + result = create_target_result() + + self.assertEqual(result.status, "OK") + self.assertEqual(result.message, "") + self.assertEqual(result.component_results, {}) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/test/test_symphony_types.py b/test/test_symphony_types.py new file mode 100644 index 0000000..d0dda71 --- /dev/null +++ b/test/test_symphony_types.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +""" +Comprehensive unit tests for Symphony SDK types and enumerations. +""" + +import unittest +import asyncio +from unittest.mock import Mock, AsyncMock +from agent.symphony.sdk.symphony_types import ( + State, + Terminable, + get_http_status, + COAConstants +) + + +class MockTerminable: + """Mock implementation of Terminable for testing.""" + + def __init__(self): + self.shutdown_called = False + + async def shutdown(self) -> None: + """Mock shutdown method.""" + self.shutdown_called = True + + +class TestState(unittest.TestCase): + """Test cases for State enumeration.""" + + def test_state_values(self): + """Test that State enum has correct values.""" + # Basic states + self.assertEqual(State.NONE, 0) + + # HTTP Success states + self.assertEqual(State.OK, 200) + self.assertEqual(State.ACCEPTED, 202) + + # HTTP Client Error states + self.assertEqual(State.BAD_REQUEST, 400) + self.assertEqual(State.UNAUTHORIZED, 401) + self.assertEqual(State.FORBIDDEN, 403) + self.assertEqual(State.NOT_FOUND, 404) + self.assertEqual(State.METHOD_NOT_ALLOWED, 405) + self.assertEqual(State.CONFLICT, 409) + self.assertEqual(State.STATUS_UNPROCESSABLE_ENTITY, 422) + + # HTTP Server Error states + self.assertEqual(State.INTERNAL_ERROR, 500) + + # Config errors + self.assertEqual(State.BAD_CONFIG, 1000) + self.assertEqual(State.MISSING_CONFIG, 1001) + + # Operation results + self.assertEqual(State.UPDATE_FAILED, 8001) + self.assertEqual(State.DELETE_FAILED, 8002) + self.assertEqual(State.VALIDATE_FAILED, 8003) + self.assertEqual(State.UPDATED, 8004) + self.assertEqual(State.DELETED, 8005) + + # Workflow status + self.assertEqual(State.RUNNING, 9994) + self.assertEqual(State.PAUSED, 9995) + self.assertEqual(State.DONE, 9996) + self.assertEqual(State.DELAYED, 9997) + self.assertEqual(State.UNTOUCHED, 9998) + self.assertEqual(State.NOT_IMPLEMENTED, 9999) + + def test_state_http_success_codes(self): + """Test HTTP success state codes.""" + success_states = [State.OK, State.ACCEPTED] + for state in success_states: + self.assertGreaterEqual(state, 200) + self.assertLess(state, 300) + + def test_state_http_client_error_codes(self): + """Test HTTP client error state codes.""" + client_error_states = [ + State.BAD_REQUEST, State.UNAUTHORIZED, State.FORBIDDEN, + State.NOT_FOUND, State.METHOD_NOT_ALLOWED, State.CONFLICT, + State.STATUS_UNPROCESSABLE_ENTITY + ] + for state in client_error_states: + self.assertGreaterEqual(state, 400) + self.assertLess(state, 500) + + def test_state_http_server_error_codes(self): + """Test HTTP server error state codes.""" + server_error_states = [State.INTERNAL_ERROR] + for state in server_error_states: + self.assertGreaterEqual(state, 500) + self.assertLess(state, 600) + + def test_state_custom_error_codes(self): + """Test custom Symphony error codes.""" + custom_states = [ + State.BAD_CONFIG, State.MISSING_CONFIG, + State.INVALID_ARGUMENT, State.API_REDIRECT, + State.FILE_ACCESS_ERROR, State.SERIALIZATION_ERROR, + State.DESERIALIZE_ERROR, State.DELETE_REQUESTED + ] + for state in custom_states: + self.assertGreater(state, 999) # All custom states > 999 + + def test_state_from_int(self): + """Test creating State from integer values.""" + self.assertEqual(State(200), State.OK) + self.assertEqual(State(404), State.NOT_FOUND) + self.assertEqual(State(500), State.INTERNAL_ERROR) + + def test_state_int_conversion(self): + """Test converting State to integer.""" + self.assertEqual(int(State.OK), 200) + self.assertEqual(int(State.NOT_FOUND), 404) + self.assertEqual(int(State.INTERNAL_ERROR), 500) + + +class TestTerminable(unittest.TestCase): + """Test cases for Terminable protocol.""" + + def test_terminable_protocol(self): + """Test that objects implement Terminable protocol correctly.""" + mock_terminable = MockTerminable() + + # Should have shutdown method + self.assertTrue(hasattr(mock_terminable, 'shutdown')) + self.assertTrue(callable(getattr(mock_terminable, 'shutdown'))) + + def test_terminable_shutdown(self): + """Test Terminable shutdown behavior.""" + async def run_test(): + mock_terminable = MockTerminable() + self.assertFalse(mock_terminable.shutdown_called) + + await mock_terminable.shutdown() + + self.assertTrue(mock_terminable.shutdown_called) + + # Run the async test + asyncio.run(run_test()) + + +class TestHttpStatusFunction(unittest.TestCase): + """Test cases for get_http_status function.""" + + def test_get_http_status_success_codes(self): + """Test get_http_status with success codes.""" + self.assertEqual(get_http_status(200), State.OK) + self.assertEqual(get_http_status(202), State.ACCEPTED) + + def test_get_http_status_client_error_codes(self): + """Test get_http_status with client error codes.""" + self.assertEqual(get_http_status(400), State.BAD_REQUEST) + self.assertEqual(get_http_status(401), State.UNAUTHORIZED) + self.assertEqual(get_http_status(404), State.NOT_FOUND) + + def test_get_http_status_server_error_codes(self): + """Test get_http_status with server error codes.""" + self.assertEqual(get_http_status(500), State.INTERNAL_ERROR) + + def test_get_http_status_custom_codes(self): + """Test get_http_status with custom HTTP codes.""" + # Test other success codes + self.assertEqual(get_http_status(201), State.OK) + self.assertEqual(get_http_status(204), State.OK) + + # Test other client error codes + self.assertEqual(get_http_status(422), State.BAD_REQUEST) + + # Test other server error codes + self.assertEqual(get_http_status(503), State.INTERNAL_ERROR) + + +class TestCOAConstants(unittest.TestCase): + """Test cases for COA constants.""" + + def test_coa_constants_exist(self): + """Test that COA constants are defined.""" + # Test some expected constants exist + self.assertTrue(hasattr(COAConstants, 'COA_META_HEADER')) + self.assertTrue(hasattr(COAConstants, 'TRACING_EXPORTER_CONSOLE')) + self.assertTrue(hasattr(COAConstants, 'PROVIDERS_CONFIG')) + + # Test values are strings + self.assertEqual(COAConstants.COA_META_HEADER, "COA_META_HEADER") + self.assertEqual(COAConstants.PROVIDERS_CONFIG, "providers.config") + + def test_coa_constants_types(self): + """Test that COA constants have correct types.""" + self.assertIsInstance(COAConstants.COA_META_HEADER, str) + self.assertIsInstance(COAConstants.TRACING_EXPORTER_CONSOLE, str) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file From f35c770eade63c8590d541e0ba9fb2c81921ade0 Mon Sep 17 00:00:00 2001 From: Naci Dai Date: Sun, 21 Sep 2025 19:04:40 +0000 Subject: [PATCH 2/5] symphony provider node and config --- config/agent.yaml | 23 +++++++ launch/agent.launch.py | 132 ++++++++++++++++++++++++++++++----------- 2 files changed, 121 insertions(+), 34 deletions(-) diff --git a/config/agent.yaml b/config/agent.yaml index 58a72e2..d01f6a7 100644 --- a/config/agent.yaml +++ b/config/agent.yaml @@ -22,6 +22,29 @@ prefix: muto namespace: org.eclipse.muto.sandbox name: hack2025-01 + + symphony_enabled: True + symphony_name: "hack2025-01" + symphony_target_name: "muto-device-001" + symphony_topic_prefix: "muto" + symphony_host: "192.168.0.47" + symphony_port: 1883 + symphony_keep_alive: 60 + symphony_prefix: muto + symphony_user: "admin" + symphony_password: "" + + # Symphony API configuration + symphony_api_url: "http://192.168.0.47:8082/v1alpha2/" + symphony_provider_name: "providers.target.mqtt" + symphony_broker_address: "tcp://mosquitto:1883" + symphony_client_id: "symphony" + symphony_request_topic: "coa-request" + symphony_response_topic: "coa-response" + symphony_timeout_seconds: "30" + symphony_auto_register: False + + commands: command1: name: ros/topic diff --git a/launch/agent.launch.py b/launch/agent.launch.py index 2cbead3..b723247 100755 --- a/launch/agent.launch.py +++ b/launch/agent.launch.py @@ -1,69 +1,133 @@ -# -# Copyright (c) 2023 Composiv.ai -# -# All rights reserved. This program and the accompanying materials -# are made available under the terms of the Eclipse Public License v2.0 -# and Eclipse Distribution License v1.0 which accompany this distribution. -# -# Licensed under the Eclipse Public License v2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# The Eclipse Public License is available at -# http://www.eclipse.org/legal/epl-v20.html -# and the Eclipse Distribution License is available at -# http://www.eclipse.org/org/documents/edl-v10.php. -# -# Contributors: -# Composiv.ai - initial API and implementation -# -# - +import os from launch import LaunchDescription from launch_ros.actions import Node +from launch.actions import DeclareLaunchArgument +from launch.conditions import IfCondition +from launch.substitutions import LaunchConfiguration +from launch.actions.include_launch_description import IncludeLaunchDescription +from launch.launch_description_sources import PythonLaunchDescriptionSource from ament_index_python.packages import get_package_share_directory -import os - pkg_name = "agent" output = "screen" def generate_launch_description(): + # Arguments + # Use this file's location to get config files + config_dir = os.path.join(get_package_share_directory(pkg_name), "config") + + + # Declare launch arguments + declared_arguments = [ + DeclareLaunchArgument( + 'enable_symphony', + default_value='true', + description='Enable Symphony MQTT provider', + choices=['true', 'false'] + ), + DeclareLaunchArgument( + 'log_level', + default_value='INFO', + description='Logging level for all nodes', + choices=['DEBUG', 'INFO', 'WARN', 'ERROR'] + ), - # Files - file_config = os.path.join(get_package_share_directory(pkg_name), "config", "agent.yaml") + DeclareLaunchArgument( + 'muto_config_file', + default_value= os.path.join(config_dir, "muto.yaml"), + description='Path to global Muto configuration file' + ), + DeclareLaunchArgument("muto_namespace", default_value="muto"), + DeclareLaunchArgument( + "vehicle_namespace", + default_value="org.eclipse.muto.sandbox", + description="Vehicle ID namespace", + ), + DeclareLaunchArgument( + "vehicle_name", description="Vehicle ID" + ) + ] + + # Configuration parameters + enable_symphony = LaunchConfiguration('enable_symphony') + log_level = LaunchConfiguration('log_level') + muto_config_file = LaunchConfiguration('muto_config_file') + vehicle_namespace = LaunchConfiguration('vehicle_namespace') + vehicle_name = LaunchConfiguration('vehicle_name') + muto_namespace = LaunchConfiguration('muto_namespace') - # Nodes + # Agent node_agent = Node( - name="muto_agent", + namespace=muto_namespace, + name="agent", package="agent", executable="muto_agent", - output=output, - parameters=[file_config] + output="screen", + parameters=[ + muto_config_file, + {"namespace": vehicle_namespace}, + {"name": vehicle_name}, + ], ) node_mqtt_gateway = Node( - name="mqtt_gateway", + namespace=muto_namespace, + name="gateway", package="agent", executable="mqtt", - output=output, - parameters=[file_config] + output="screen", + parameters=[ + muto_config_file, + {"namespace": vehicle_namespace}, + {"name": vehicle_name}, + ], + arguments=['--ros-args', '--log-level', log_level] ) node_commands = Node( + namespace=muto_namespace, name="commands_plugin", package="agent", executable="commands", - output=output, - parameters=[file_config] + output="screen", + parameters=[ + muto_config_file, + {"namespace": vehicle_namespace}, + {"name": vehicle_name}, + ], + arguments=['--ros-args', '--log-level', log_level] ) + # Symphony Provider (conditional) + + symphony_provider = Node( + namespace=muto_namespace, + package='agent', + executable='symphony_provider', + name='muto_symphony_provider', + output='screen', + condition=IfCondition(enable_symphony), + parameters=[ + muto_config_file, + {"name": vehicle_name}, + {"namespace": vehicle_namespace}, + {"symphony_target_name": vehicle_name}, + ], + arguments=['--ros-args', '--log-level', log_level] + ) # Launch Description Object ld = LaunchDescription() + # add all declared arguments + for arg in declared_arguments: + ld.add_action(arg) + + # add all nodes ld.add_action(node_agent) ld.add_action(node_mqtt_gateway) ld.add_action(node_commands) + ld.add_action(symphony_provider) - return ld \ No newline at end of file + return ld From 0b1d2856561c0cb733bcb0f631b2ee1e78f7f7bc Mon Sep 17 00:00:00 2001 From: Ibrahim Sel Date: Mon, 22 Sep 2025 18:21:27 +0300 Subject: [PATCH 3/5] feat(reconciliation): elaborate get-apply-remove trio to communicate with muto composer --- agent/symphony/symphony_provider.py | 322 +++++++++++++++++++++++----- 1 file changed, 263 insertions(+), 59 deletions(-) diff --git a/agent/symphony/symphony_provider.py b/agent/symphony/symphony_provider.py index b378727..0e18f5f 100644 --- a/agent/symphony/symphony_provider.py +++ b/agent/symphony/symphony_provider.py @@ -30,7 +30,7 @@ import json import signal import threading -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple # Third-party imports import rclpy @@ -102,6 +102,8 @@ def __init__( self.stack_publisher = self.create_publisher( MutoAction, topics.stack_topic, 10 ) + + self._component_registry: Dict[str, Dict[str, Any]] = {} def _do_initialize(self) -> None: @@ -355,59 +357,79 @@ def apply( ) # Use summary models - result = SummarySpec(target_count=1, success_count=1) + result = SummarySpec(target_count=1) target_result = TargetResultSpec() - + successes = 0 + failures = 0 + + target_name = metadata.get("active-target", self.get_target_name()) + for component in components: + component_name = component.name or "unnamed-component" self.logger.info( - f"Deploying component: {component.name} of type " - f"{component.type}" + f"Deploying component: {component_name} of type {component.type}" ) - - component_type = component.properties.get("type", "") - content_type = component.properties.get("content-type", "") - data = component.properties.get("data", "") - - if (component_type == "stack" and - content_type == "application/json" and - data): - try: - stack_json_str = base64.b64decode(data).decode('utf-8') - stack_data = json.loads(stack_json_str) - self.logger.info( - f"Decoded stack data for {component_type} " - f"{component.name}: {stack_data}" - ) - - msg_action = MutoAction() - msg_action.context = "" - msg_action.method = "start" - msg_action.payload = json.dumps(stack_data) - self.stack_publisher.publish(msg_action) - - # Implement actual deployment logic using stack_data - except Exception as e: - self.logger.error( - f"Failed to decode or parse stack data for " - f"{component_type} {component.name}: {e}" - ) - + component_result = ComponentResultSpec() + + stack_payload, decode_error = self._extract_stack_payload(component) + if decode_error: + failures += 1 + component_result.status = State.UPDATE_FAILED + component_result.message = decode_error + target_result.component_results[component_name] = component_result + self.logger.error( + f"Component {component_name} payload error: {decode_error}" + ) + continue + + publish_method = self._resolve_component_method(component, default="apply") + published = self._publish_stack_action( + method=publish_method, + payload=stack_payload, + context=component.metadata.get("context", "") if component.metadata else "" + ) + + if not published: + failures += 1 + component_result.status = State.MQTT_PUBLISH_FAILED + component_result.message = ( + f"Failed to publish apply action for component {component_name}" + ) + target_result.component_results[component_name] = component_result + self.logger.error(component_result.message) + continue + + successes += 1 component_result.status = State.UPDATED component_result.message = ( - f"Deploying component: {component.name} of type " - f"{component.type}" + f"Apply action published for component {component_name}" ) - - target_result.component_results[component.name] = component_result - self.logger.info( - f"Deploying component: {component.name} of type " - f"{component.type}" + target_result.component_results[component_name] = component_result + + self._component_registry[component_name] = { + "component": to_dict(component), + "payload": stack_payload, + "status": "applied", + "state": State.UPDATED.value, + "last_action": publish_method, + } + + target_result.status = "OK" if failures == 0 else "FAILED" + if failures: + target_result.message = ( + f"{failures} component(s) failed during apply" ) - + + result.success_count = successes + result.current_deployed = successes + result.planned_deployment = len(components) + if failures: + result.summary_message = target_result.message + target_result.state = SummaryState.DONE - result.update_target_result(metadata["active-target"], target_result) - + result.update_target_result(target_name, target_result) + return json.dumps(result.to_dict(), indent=2) def remove( @@ -432,9 +454,78 @@ def remove( f"Symphony remove: removing {len(components)} components" ) - results = [] - - return json.dumps([to_dict(result) for result in results], indent=2) + result = SummarySpec(target_count=1, is_removal=True) + target_result = TargetResultSpec() + successes = 0 + failures = 0 + target_name = metadata.get("active-target", self.get_target_name()) + + for component in components: + component_name = component.name or "unnamed-component" + self.logger.info( + f"Removing component: {component_name} of type {component.type}" + ) + + component_result = ComponentResultSpec() + + stack_payload, decode_error = self._extract_stack_payload( + component, + allow_registry_lookup=True + ) + + if decode_error: + failures += 1 + component_result.status = State.DELETE_FAILED + component_result.message = decode_error + target_result.component_results[component_name] = component_result + self.logger.error( + f"Component {component_name} payload error: {decode_error}" + ) + continue + + publish_method = self._resolve_component_method(component, default="kill") + published = self._publish_stack_action( + method=publish_method, + payload=stack_payload, + context=component.metadata.get("context", "") if component.metadata else "" + ) + + if not published: + failures += 1 + component_result.status = State.DELETE_FAILED + component_result.message = ( + f"Failed to publish remove action for component {component_name}" + ) + target_result.component_results[component_name] = component_result + self.logger.error(component_result.message) + continue + + successes += 1 + component_result.status = State.DELETED + component_result.message = ( + f"Remove action published for component {component_name}" + ) + target_result.component_results[component_name] = component_result + + # Drop from registry once removal is confirmed + self._component_registry.pop(component_name, None) + + target_result.status = "OK" if failures == 0 else "FAILED" + if failures: + target_result.message = ( + f"{failures} component(s) failed during removal" + ) + + result.success_count = successes + if successes: + result.removed = True + if failures: + result.summary_message = target_result.message + + target_result.state = SummaryState.DONE + result.update_target_result(target_name, target_result) + + return json.dumps(result.to_dict(), indent=2) def get( self, @@ -458,22 +549,135 @@ def get( f"Symphony get: retrieving state for {len(components)} components" ) - # Return the requested components or empty list if none specified - # In a real implementation, this would query the actual system state + # Determine which components to include in the response if not components: - current_components = [] + component_names = list(self._component_registry.keys()) self.logger.info( - "No specific components requested, returning empty list" + "No specific components requested, returning tracked components" ) else: - # Return the requested components as-is for now - # In real implementation, query actual deployment status - current_components = components - self.logger.info( - f"Returning {len(current_components)} requested components" + component_names = [comp.name for comp in components if comp.name] + + reported_components: List[Dict[str, Any]] = [] + for component_name in component_names: + state_entry = self._component_registry.get(component_name) + if not state_entry: + self.logger.debug( + f"Component {component_name} not found in registry; skipping" + ) + continue + + component_info = dict(state_entry["component"]) + component_info["status"] = state_entry.get("status", "unknown") + component_info["state"] = state_entry.get("state", State.NONE.value) + component_info["last_action"] = state_entry.get("last_action", "") + reported_components.append(component_info) + + return json.dumps(reported_components, indent=2) + + def _resolve_component_method( + self, + component: ComponentSpec, + default: str + ) -> str: + """Determine the composer method for the provided component.""" + props = component.properties or {} + method = props.get("method") or props.get("action") + + if not method and component.parameters: + method = ( + component.parameters.get("method") + or component.parameters.get("action") ) - - return [to_dict(comp) for comp in current_components] + + if not method and component.metadata: + method = ( + component.metadata.get("method") + or component.metadata.get("action") + ) + + resolved = (method or default).lower() + + if default == "kill" and resolved == "start": + # Prevent accidental start when the default is a destructive action + return default + + return resolved + + def _extract_stack_payload( + self, + component: ComponentSpec, + allow_registry_lookup: bool = False + ) -> Tuple[Optional[Any], Optional[str]]: + """Extract a JSON payload representing the stack for a component.""" + try: + props = component.properties or {} + data = props.get("data") + + if data is None and allow_registry_lookup: + registry_entry = self._component_registry.get(component.name or "") + if registry_entry: + return registry_entry.get("payload"), None + return None, "Component stack payload not available" + + if isinstance(data, dict): + return data, None + + if isinstance(data, bytes): + decoded_bytes = data + elif isinstance(data, str): + decoded_bytes = self._attempt_base64_decode(data) + if decoded_bytes is None: + decoded_bytes = data.encode('utf-8') + else: + if allow_registry_lookup: + registry_entry = self._component_registry.get(component.name or "") + if registry_entry: + return registry_entry.get("payload"), None + return None, "Unsupported payload format" + + payload_str = decoded_bytes.decode('utf-8') + return json.loads(payload_str), None + + except json.JSONDecodeError as exc: + return None, f"Failed to parse stack data: {exc}" + except Exception as exc: + return None, f"Unexpected error reading stack payload: {exc}" + + def _attempt_base64_decode(self, data: str) -> Optional[bytes]: + """Try to base64 decode a string, returning None if decoding fails.""" + try: + return base64.b64decode(data) + except Exception: + return None + + def _publish_stack_action( + self, + method: str, + payload: Any, + context: str = "" + ) -> bool: + """Publish a stack action to the composer for execution.""" + if not self.stack_publisher: + self.logger.error("Stack publisher is not initialized") + return False + + try: + payload_str = payload if isinstance(payload, str) else json.dumps(payload) + + msg_action = MutoAction() + msg_action.context = context + msg_action.method = method + msg_action.payload = payload_str + + self.stack_publisher.publish(msg_action) + self.logger.debug( + f"Published stack action '{method}' with payload length {len(payload_str)}" + ) + return True + except Exception as exc: + self.logger.error(f"Failed to publish stack action '{method}': {exc}") + return False def needs_update( self, @@ -697,4 +901,4 @@ def signal_handler(signum, frame): print(f"Error during ROS2 shutdown (this may be normal): {e}") if __name__ == '__main__': - exit(main()) \ No newline at end of file + exit(main()) From 710297b46168ce89b3b0e2d43216c8a8d8d45e60 Mon Sep 17 00:00:00 2001 From: Naci Dai Date: Tue, 23 Sep 2025 15:47:04 +0300 Subject: [PATCH 4/5] Updated scripts for dfifferent platforms --- config/define-instance.sh | 2 +- config/define-solution.sh | 84 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/config/define-instance.sh b/config/define-instance.sh index 52d7c17..4d216fc 100755 --- a/config/define-instance.sh +++ b/config/define-instance.sh @@ -1,6 +1,6 @@ #!/bin/bash -export SYMPHONY_API_URL=http://192.168.0.47:8082/v1alpha2/ +export SYMPHONY_API_URL=http://localhost:8082/v1alpha2/ TOKEN=$(curl -X POST -H "Content-Type: application/json" -d '{"username":"admin","password":""}' "${SYMPHONY_API_URL}users/auth" | jq -r '.accessToken') diff --git a/config/define-solution.sh b/config/define-solution.sh index 83c0db8..4f69936 100755 --- a/config/define-solution.sh +++ b/config/define-solution.sh @@ -1,8 +1,22 @@ #!/bin/bash # Script to create and deploy solution with base64 encoded stack data +# Compatible with Ubuntu, macOS, and WSL # Usage: ./define-solution.sh +# Function to check if a command exists +check_command() { + if ! command -v "$1" &> /dev/null; then + echo "Error: Required command '$1' not found. Please install it." + exit 1 + fi +} + +# Check required dependencies +check_command curl +check_command jq +check_command base64 + # Check if JSON file argument is provided if [ $# -eq 0 ]; then echo "Usage: $0 " @@ -22,14 +36,57 @@ fi ROOT_NAME=$(basename "$JSON_FILE" .json) SOLUTION_NAME="${ROOT_NAME}-v-1" -export SYMPHONY_API_URL=http://192.168.0.47:8082/v1alpha2/ +export SYMPHONY_API_URL=http://localhost:8082/v1alpha2/ # Get authentication token -TOKEN=$(curl -X POST -H "Content-Type: application/json" -d '{"username":"admin","password":""}' "${SYMPHONY_API_URL}users/auth" | jq -r '.accessToken') +echo "Authenticating with Symphony API..." +TOKEN=$(curl -s -X POST -H "Content-Type: application/json" -d '{"username":"admin","password":""}' "${SYMPHONY_API_URL}users/auth" 2>/dev/null | jq -r '.accessToken' 2>/dev/null) + +if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "Error: Failed to authenticate with Symphony API at $SYMPHONY_API_URL" + echo "Please check that Symphony is running and accessible." + exit 1 +fi + +# Function to encode base64 in a cross-platform way +encode_base64() { + local file="$1" + + # Try different base64 approaches for maximum compatibility + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS (BSD base64) + base64 -i "$file" 2>/dev/null || base64 < "$file" 2>/dev/null + elif [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]] || [[ -n "$WSL_DISTRO_NAME" ]] || [[ -n "$IS_WSL" ]] || [[ "$(uname -r)" == *Microsoft* ]] || [[ "$(uname -r)" == *microsoft* ]]; then + # WSL/Windows environments - try different approaches + if base64 -w 0 "$file" 2>/dev/null; then + base64 -w 0 "$file" + elif base64 < "$file" 2>/dev/null; then + base64 < "$file" | tr -d '\n' + else + # Fallback for older WSL versions + cat "$file" | base64 | tr -d '\n' + fi + else + # Linux/Unix (GNU base64) + if base64 -w 0 "$file" 2>/dev/null; then + base64 -w 0 "$file" + else + # Fallback for systems without -w option + base64 < "$file" | tr -d '\n' + fi + fi +} # Base64 encode the contents of the JSON file -STACK_DATA_BASE64=$(base64 -w 0 "$JSON_FILE") +echo "Encoding stack data to base64..." +STACK_DATA_BASE64=$(encode_base64 "$JSON_FILE") +if [ -z "$STACK_DATA_BASE64" ]; then + echo "Error: Failed to base64 encode the JSON file" + exit 1 +fi + +echo "Base64 encoded stack data length: ${#STACK_DATA_BASE64} characters" # Create the solution JSON in a variable SOLUTION_DATA=$(cat << EOF { @@ -69,7 +126,24 @@ echo "Base64 encoded data length: ${#STACK_DATA_BASE64} characters" echo "Posting $SOLUTION_NAME solution to Symphony..." # Try to delete existing solution first (ignore errors if it doesn't exist) -# curl -s -X DELETE -H "Authorization: Bearer $TOKEN" "${SYMPHONY_API_URL}solutions/$SOLUTION_NAME" > /dev/null 2>&1 +curl -s -X DELETE -H "Authorization: Bearer $TOKEN" "${SYMPHONY_API_URL}solutions/$SOLUTION_NAME" > /dev/null 2>&1 # Post the new solution -curl -s -v -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d "$SOLUTION_DATA" "${SYMPHONY_API_URL}solutions/$SOLUTION_NAME" +RESPONSE=$(curl -s -w "\n%{http_code}" -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d "$SOLUTION_DATA" "${SYMPHONY_API_URL}solutions/$SOLUTION_NAME" 2>/dev/null) + +# Extract HTTP status code (last line) and response body (everything else) +HTTP_STATUS=$(echo "$RESPONSE" | tail -n1) +RESPONSE_BODY=$(echo "$RESPONSE" | head -n -1) + +if [ "$HTTP_STATUS" -eq 200 ] || [ "$HTTP_STATUS" -eq 201 ]; then + echo "✅ Solution '$SOLUTION_NAME' created successfully!" + if [ -n "$RESPONSE_BODY" ] && [ "$RESPONSE_BODY" != "null" ]; then + echo "Response: $RESPONSE_BODY" + fi +else + echo "❌ Failed to create solution. HTTP Status: $HTTP_STATUS" + if [ -n "$RESPONSE_BODY" ]; then + echo "Error details: $RESPONSE_BODY" + fi + exit 1 +fi From 2e48dc8e20ce01a7535340e3e69d66c7b5bd9789 Mon Sep 17 00:00:00 2001 From: Ibrahim Sel Date: Tue, 23 Sep 2025 19:09:39 +0300 Subject: [PATCH 5/5] fix(provisioning): adjust docs and stacks for the new provision plugin --- README.md | 8 +++---- config/solution.json | 22 -------------------- config/stack.json | 12 +++++++++++ config/talker-listener-archive-solution.json | 22 ++++++++++++++++++++ 4 files changed, 38 insertions(+), 26 deletions(-) delete mode 100644 config/solution.json create mode 100644 config/stack.json create mode 100644 config/talker-listener-archive-solution.json diff --git a/README.md b/README.md index 935cbfb..b2c5cae 100644 --- a/README.md +++ b/README.md @@ -313,11 +313,11 @@ def generate_launch_description(): ], ) - node_native_plugin = Node( + node_provision_plugin = Node( namespace=LaunchConfiguration("muto_namespace"), - name="native_plugin", + name="provision_plugin", package="composer", - executable="native_plugin", + executable="provision_plugin", output="screen", parameters=[ muto_params, @@ -352,7 +352,7 @@ def generate_launch_description(): ld.add_action(node_twin) ld.add_action(node_composer) ld.add_action(node_compose_plugin) - ld.add_action(node_native_plugin) + ld.add_action(node_provision_plugin) ld.add_action(node_launch_plugin) return ld diff --git a/config/solution.json b/config/solution.json deleted file mode 100644 index 5bc63c2..0000000 --- a/config/solution.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "metadata": { - "namespace": "default", - "name": "talker-listener-v-1" - }, - "spec": { - "displayName": "talker-listener-v-1", - "rootResource": "talker-listener", - "version": "1", - "components": [ - { - "name": "talker-listener-stack", - "type": "muto-agent", - "properties": { - "type": "stack", - "content-type": "json", - "data": "jajaja" - } - } - ] - } -} \ No newline at end of file diff --git a/config/stack.json b/config/stack.json new file mode 100644 index 0000000..1593c2a --- /dev/null +++ b/config/stack.json @@ -0,0 +1,12 @@ +{ + "name": "talker-listener", + "artifact": { + "type": "archive", + "filename": "talker_listener.tar.gz", + "algorithm": "sha256", + "checksum": "553fd2dc7d0eb41e7d65c467d358e7962d3efbb0e2f2e4f8158e926a081f96d0", + "data": "H4sIAAAAAAAAA+1de3PbxhH33/gUV7oNqQkJvsWOIqpVbafW1JFTS2macT3gETiSiEAcigMssZl89+7eHUCAFEXJomhncuuxSOBee7d7v9170uXhxJ82nz0ltYAGgz5+tgf9VvEzo2ftfuuw12/1um143271+t1npP+kXGlKRUJjQp4JFtwZb1v4b5RcJf+EBlcsthd0/gS1RAEfHvY2yr/dy+Xf73YOQf6dw07vGWntnpV1+p3LX0v+yCIk5sJxIhrTOUtYLPAVIVE6DnwxcyYx+2/KQndxRNp2SwbNmRB0yo5I5eLlv8hr6l7RZMZD8mJGI8iAnJ2d/aHyuetn6G4KaBq6sy8F/9ut9qHCf/gw+L8H0vJXKOBAV09YCIZAvbajxS7K2IL/7W6vt8T/Fsq/3xsY/N8LTWI+J0raxJ9HPE7IG/n0kgk39qPE56FViOSAlbCpi69FluCce8zS37mwLMtjEzJFPaIJc3Q6b5lf7UDZFq10ygUhQ0hrR2BCbDoW+FmTkZCygJ+5Hy7fFkM8Pw7BbtUcZ+IHzHEO6qVoFduurLxRpa6+LfhBhaADa/k3hMo6KhqwjFVfcoQsDHUeheQRmsYphMzThDtrweyGuWlCx8HGGDxNojQZVqAFGQtLOWfWevi+1Jof6prlJc9Z397AdRa8ke9bIqxxfkucW3nXfAUeMLOmbrUDHWhTz3OUrtUKzb4xOCtdRYhZksYhxLM+dx/7kknE7tMa/2cPGv91wQSg/W+1Osb+74NQ/qWu+wTacG/598AD7Hfk+G9waOS/D1qXf8wET2OX7U4RHi7/Xr/TN/LfB90h/9Lrx5Sxbf5nXf6Dbmdg/P990Lr8d28NHj7+7/f7A9P/90Hb5O84fugnjvOYqYBP6P+Hh4em/++Dtsm/9PSJSrBt/qfdb+fy77bbz1qddgvtv5H/05OetondIFqoeR751cYBdWl+R4aJxHPmYips+JOFXiSxH04tyw2oEOQ70Jc3Wl1qmFDP9eCUUIYlNWjLiX6PJNIIIh/YeXi1pHXVg2VMSGhPWeIEfDqVafxwwmvVYqlytoPMqCBjxkKC4k2YZ6/m4sYMZ6dEOl5OPKiq1EnVndEkgZLrKm7GiePSIBhT96pO2i09h4EVWwuXNawTaKViNW9nflI9IzNGY++I/AIJbI8m9FfgVk2jzakf1mg8FcNzHmaNqUSEjaWC8I+qXqnhyLAsjoNCYhFBvqXYt2Rge0wkMV842KTl5LM08fg1ztVYPkoWJ5EchwyHpOo4yLTjVBWzsgYHZhLmy6R1/BcsSaMdzfwr2jr/3+msjv96LWP/90IK1lHkCedBPqU/8UPP0fO/oq4iWJZ+ITs7gMsKTFuWjKYmluWkcjG+mvj9yGIBSDustmz4V1Uvs3KGpVJr7MYNUo8N31cTgKHqBz2nj/goZ/nF8H2OrbWqmNGYNaGgMAEz4rGbfCCrH7N8q+Up//fVfMBbJV+TIssfCqsIWQGrceqQgX62b+ZBzqWegPdDUK8gcHD13I+R5eqysas60v/8yBF0woaXcarbCUEzgf8sHkKCoLr61mHwPRhW/XFMZ/7chjh/xXUAinbMdvlcpyisugyrl29fvj0i3ytui0E6cuC7LBQsi/hGPUJEMO0qZx0R5SGyOkGVooWSkAoFCcQLJ+LAqxj+krdg1eWh4AGYXFmsqB6R9yVJVFeNV9kWlZ6OsCmqeXJd8q91yxiah9E6/he0eUdlbPX/B/0V/O9DBIP/+6Djv4Ccc1iutO1W5S8nlnzbmIPXF5BZzCbDyixJoqNmE32+gFPPxmVgHk+bwp0BFGVK40x4PKdJ174RXoWosGQRAYzkOVxfX9vXXZm2A7Jv/vu7NxcynixX50NUPsNKt3ICPfsYkfakpKbHTfkOAzXzJ9KkHDezRwwqYNzJRvQ7bhajYbIlzhKFs5WNOFs5gVfHzWUKmYFG0pONQHrczKLgKOIY4dPxGDSUd6JMmMujRexPZ8lxsxh4e+RJQK/Yn+8TM2JRpz+4Oyag+YyH3YZC9ZW4GJndoI9wIiH3eJz6geegkLMiZPLjZiEAEzWzVMeZspwYqP78tMn/dyfTnZWxbf6v3R2sr/+0DP7vg9577CMLePTBUhDoeH48/OOYCgYINS6rhvVeu7MfrMyv1b7cxhSfu3qGttB6/0e43+0mgIev/3YP+2b9dy+0Qf7S5iu34vFTQdv8//5hb0X+8NXM/+yFnpMXmadJOiAW8hb8PHIhJ0TIOz7mie8K8i1PQ086rnVyFrq29dx6nrm1HoEw8JSTGSOn4NrBhw6pk38pX5x07BapYYSKDqocfAM5LHhK5nRBQp6QFBzkZOYLglM7hN24LEqIHxLwsaPApyGwc+0nM1mMzgTYID/pLPgY3W9CCTrOhE+K8QhNJMNIhVEIlczKkYh2xkXzzdmLV+cXrxrAsEzyQxgwIYie6vDIeEFoBPy4uOmRBPSa8JjQacwgLOHI73XsJ3IRQfBJck1jBrl40Ldif5wmpcbKuIM6FyNAc9GQVE4vyNlFhfzt9OLsog55/Hh2+frtD5fkx9N3707PL89eXZC378iLt+cvzy7P3p7D07fk9Pwn8o+z85d1wqCpcPRyE8XIPzDpYzMyD9vsgrESAzDYks8iYq4/8V2oVzhNcZQ05TCaCqE6JGLx3BdCbvuloQe5BP7cT6jaCLxWKdtSC0bF8YmNg6RsglEuEaBAHRbHPBbZ/mE16LAs66/qGySKr2yVQfldACMu8DBwkaSAVrV8iQQaQeaME0krheGqycfh+w9qRYMKweQSGK5etOrkP8spK6n35E8eaJUH7ZMsUDVVrk0CwsWmEUf/CavkTyRgYU2FHZCvi5lAsNq5rEO/KKfoLvzPx6CPNAHb8H/QWp3/H3QGBv/3Qiv43zf4b/B/5/i/BJJVE7AG+8/JOzaHUmVmI3HlRyOcN+MxTYBLjmogWVUKirpSA7ydUUihZK9UecYocFQ2GJhZLWZU4FrIOV+LvdwzEAXUBUkAq1hWdpLFK5aK+wlKuee53W2l8mhLQ6UNlDZKVbtaJ9mK1632SVslZU2qj7Qmd+G/mix8ev+/1+uu+v/ddsfg/z7I4L/B/yfHfw0kW8H/NuQsvVMZLdFUPT8aSu/y7h8LsF845fivztY9yVmwB83/teT5H7wGwsz/7YFW5b/70z+fIv9e28z/7oc2yr/w8rFlPGj/f0vN/7bN+f+90Kr8d7/78x77P9ud1f7f6pr9/3uhx+//VIpjdn+a3Z872P2ZX6xReLIL382+zx3TKv7vfvfnffZ/dlfxH74Z/N8H/db2fyo1Nbs/ze5PQ7ugVfx/irmge4//8/P/vcN+24z/90F3yX8XZ/+RPmH8P8D7f439f3q6S/5FH/wxKrBF/p1Wv5/f/9jr4/1fg765/2E/9BTn/y+lyjzy9L+eVrjf2X9V4j1P/itHjC2vOq5V1245rtbxmuPtCfUdyLjO9JoFAa+TzXchF/go56tLh3E9DHyLlxPkAbfdTNBeZS/x53LoXMxBvqvlTXdnlQ/KUZyPNEgZNLPHISpTj/VCUfltB6s3KyTARGt5O0I58qr4UZOGWolqB8XX8iaErD4r3GftvolnIfNTj+RrMqmSXzRvv1Y3NX72tYbXNqxV6eshaW+/yuF7ndvaZQ6fepdDPiFS6Fi33+NQvByziNzmDgdDm+n2+f9dnv66z/mvtfmfds+s/+yF7nP+S6nG/U9/6fifu2qG7kGr/X/3p78+Zf2/222Z9f+90K3y3+nprwee/1Lj/5bZ/78fMue/zP5Pc/7LnP9ax/9dnf564PkvPf/bMfi/FzL7/w3+m/Nf5vzXOv7v6vTXA89/SfzvD3rm/NdeyOC/wX9z/uv3e/7r3avTl9+9sufeE5axBf/7ncNB/vvPh335+599c//zfui5/hXGRnb+u3EtLOuUvHt7QTrke7mbj1zz+EoAWiJsUOiv1IcuQnVKhIP8Bny1BM+V4zTSi9UjgMbId21yCS91omzZFfNB+ETMVO4dIA0R4PGFXl1mjTnl2Qd8KggDUFoAJrvM/wiAqdeCbfJDZkKimP/M3AS6O+Q+90N/TgMADEBAcOMk3AEwsthHcMJypWFRNc6Xg/HUg/ptgLF8kLVUv5Mpjx8Awj1HZzXiwgfXdEHeUDBnidWAaq/9pvaIfDX3qJh9s/zFRmXncC+tMiuymip/taxCam4ax8BhsMBldD+EthqtrdqPJGMj3QajAxsZ2Pajrkt25B4J8Ho5NACKThBpRAW0CnAH7eii+UjxWXKoqpaqbayEjsE+yBJX3chlCVqFso29UoFwMyhYW6UkhWQjyUM5w/xegodmmSXMMkVxfR8zacpBZExAMZme5/YXa1X7lt8s6uTvNMAfl3Tr5HU6B2tfRyv6jgdgl6YHSmU0IyKN0I7ZSvYBtJGWSsyFx2AAo9fLQNCoe3JrLDYoxsHjJ8FHfFJ7bEGmPioX8qaGG57mkYUf/ZiHqLOkNtJDkSaPkiYU0zyWVeAnegF3DA2FyoCV/huWZ41GI3wJhvtbNMzYTstuHXOeWIrbjFnSaKAFb+DPqwoC4oAX/jTkMWvIh5g0FpaqraqRpVnS6QuMYNkWOmouODYCHDTlfshU6DXxkOXCBFFSuWOE1Jg9tcmo0ciO7jQAgrFXFxUGBDHDfuSrXdnhVHfLNLROJ9jLJFPYvKN1vkZHVqFZIA1h4BEqCBMMe2rCggW2S4fEEFrckFFc6CxHyLGqfBMmOsyqexc6W8KnDB01lYXu/o2G+tJAhJDtT7Z1aNnCJ+TSj47I2UT61eD4TYCJUTHnYgU2gwNgnecV8UgiFchJ9q3lAawRwrTsMdrdzmQoWxhaHqSCzu1S+5WbOFJnqYpwoQoagfBuNRGjbN9ZE/401V6hUYb7oJwpMAlYP6rIPVh3bMEi3crom7JFiWI8qaQkH9EFArL05OGd2tyjNOpFEfgkl8XGwVTiVuCvo4JCtDUsP0KZYRRLx0cfFoTlLHczCeXWrmH+Ee5NU/tyVBMckcrW3WcVqSPIeLa1L2CIJQrJReJDl0+FNACAcHhcYULTAFqh1iav/5dbYrWxiogZv9b4fwCGN/LQsI42bx5FF0CfgGAa96jMULDCjxgTX+nuNQWMgw4lcSA3OWM/8JOFEsclOPXIx1cw4JA2XNYsQwqogzw8qDmG8mjslV0aNboAoM0AQEMZjhbInZiz0q9Vq4Kazzi/Ah0orniA9EfFEdBIuRHLiScYf7pX2pU4Zze4mZJFaJhe3UDuqsWz/rpsv0KDYXY4ct7qGCCw6haVxgV6+BywPNMggic15Djxn/yCRBxGuL7Ep4KnVHSTYFQ+T0MYBmvtAd2eJjPMOTtig6VnpqToNyEuQLVgHA2lg9FycYgpR9MffVqCq5EsEHwqHA8DG8gTOp9Yzud2mw0ZMmTIkCFDhgwZMmTIkCFDhgwZMmTIkCFDhr5Y+j9F46SUAKAAAA==" + }, + "launch_description_source": "launch/talker_listener.launch.py", + "flatten": true +} diff --git a/config/talker-listener-archive-solution.json b/config/talker-listener-archive-solution.json new file mode 100644 index 0000000..5bbc8ee --- /dev/null +++ b/config/talker-listener-archive-solution.json @@ -0,0 +1,22 @@ +{ + "metadata": { + "namespace": "default", + "name": "talker-listener-archive-v-1" + }, + "spec": { + "displayName": "talker-listener-archive-v-1", + "rootResource": "talker-listener-archive", + "version": "1", + "components": [ + { + "name": "talker-listener-archive-stack", + "type": "muto-agent", + "properties": { + "type": "stack", + "content-type": "json", + "data": "H4sICLqd0mgAA3N0YWNrLmpzb24AbXjHDsNKkuS9v+LhXbnbRW8WmAO9EZ0k+kuD3pOiN4P59+HrRh92MDoQUmaqSFZmRUbkf/7tjz/+HOI+//P//fHnGndtPv/frl7WfMjnP//PX854XusiTtcn4D+f349lvX7/DI/ntKr3/J9hj7mou/z/X+kf/17p72s8/728/x0Zd+U412vV/xW6VDFKkP92pVWetsv2Tw9BYEWGZimVwXmCIzmVkUSKk1SGEXROMSSaYXmRJHCOFmiOFzTymBmUjGEaKRgyg/+9Zhav8V/rKfiisv/+QEiWY3ZyVgqGgdL1qNGc3q22L76lTj8viQsf0STni/Zx6mCl6NTfWmTTujSWJepoBqJyxeVZSQy0sa+GdNvOr8Xlz0NSO4NQ8BZUp0ajww13a8jK5U1Aa1kw3i7eI1qsxbYdCAqQrUdwDKUQaEWGnw21gdy9Pq7WvH+4Jq1HlCA7nhCfVCsgkevSZa0yGJRf5MOKhbKH6DWD7IKCgsJC612Q2QFTu+wP6/DzEOiH6QG8QxevtsSinWo1O5a7hrrNeqonksJwnOkVnhAKXu8hOz/mD/0eidl/OPgaCZXI9W6nq5nsqE9kRALda7JK1+ygoRmIlWvL14GU8VfMTeRy0RJgtpWZFL4A5QHptALvWvCyONaDmZBXI3ZP4ub8Ui9U1QHlk/tKO0BaJoCdSwhMWnd8FdLgOT41RrFFSpXBALdlizGTLRKk5NXWl+0S/leTjNRtfJrT9xej+kM31ka8NcmOVS3F3mRCdAXlCpE0IfBVutUbruYPyvGJMPqQ1DttxF+lBhHKRRVSa9D2QXHJ6qYRpCq3Ilrk5I1Stm2zfn5+MQnpRcVVMSvEoMUqYyRf1XQ0klek43sRFDev8venX8JfZdxOJazzkV/y5nf8SSuyLJzm2Lwjb2Il+b3qll/4+V5DiDYW5G/utJSJMIZis09MtzmTqNN5Fu2W+tgQFKybG7rVJ7MrKH7l3Jjlzqv7uhP8tPg1+4RV1C5hdeoGlJ2AattBpFYjBmhqy08RHu9vCXwIsZYEonDzDSZ/gw4nTmVAMDQrUErRQjJzxNYXCMgUFPQhOqgVUC7+UWoKBDNwTKAXHANXgNHnk/ieSUE8D0YKSZ+VwGYYWJ/pDKVNpaEgChQUy9iAgZWEsGi82Mqh2a1wCjmbtCGlwvMeQJCwLj1g7K/2nbkVTEWWQMVFUUlyI1OWlfu6EwqACStfxyFTpWnMPxSBpCGX+R30wWPtVOw+rH8LpFlSUH9PBcbX60BH0XpT8FMBfodMvSTYTM67rhp2pt/odUDamqmqagXCPKiRnxJ4JeoZX7my/VwVC2Hy4uI8jn73wqm71tE2Jp5rzvbF4y0aRiTo8SU0IjITrY6w7Fc9IeLgDZnYlpE+v3pKrnP7q3KVw5A/C61hpsJFzz+2wIyfTX95tekzQ9GpjHqbcghBKmBVLVHpnpGK0mvR5o7bAulLWkrNW+ljc3K/YhP7PK1CUzAN1aWzSzOafFhJ3D4F4Yp+yhw+WsOjcfTldIRuwHRRIvfqhXY7+hohptVxKT9rxC/daTzOv5cyVKJK7y/ygwAuXb6xkd3FMaPNDDCoX5qdIXX/rxyqL6RTWj7POHtyAe3aNlxYYJzw2teek+Is1AOFL0fa/OYV9t3mt2kjQ/3eMJescl9hW9b+xj3E3RfsJf88baaM0HRH2sSQ1ClfysTd6NCAcozAINEKas2JP/XP1pixXBqseOOznBQ8PyuUSU+Q+wOg8AqDSzQPctRlKfhpUf23PKf59C4DEk2giLsnPy/tTHBfdJkqAoKp0IfIp2uJFxLeAX1xEnyMcZDGc8K74l0W9KI8OR71mUR65bA/lQ8CyYgAlVZNBc7AZeshMbK9S39XvWzlNXcoU+wNXGPXutWwSKYbT+nce6lBJduuJhoWPEUn/CG+8mW1sjeO/rdlR4lP37oG6ex0NlA7ZyWS+xXOymEg9cvEkR/m8jRPyk2Paf0uIq2F76c80DJ6YpDrrOOKEUlQ5XDuZl1iB4ADsAcsRuBUG0Ck0Ow4n6ZIva0W4hNfeuiGO+lVO5AILrof9LvqUxsnsxcxFCol2z57r1RaKDl+MCfJ3guHT9x+ZRpC4Z9g9MdFfn/Y8G4t7ywCZg+CqEdZStvBvlHm0zmG+gwI2HTtxXytmMrbxRK2Rdz+XoniFK3F3sGtMa9WEfL7cxxTaN7aZ39TSRU3YaJ2d/WSFsI3redaeoVy++6rDyUZ/lnmW5nT+6WXL0sN8cw+ZeHrZqRm9MbWfPLTjujuXM4HUaCP9n6BcIBox0C1z++GcOuKSyi8FCyMeCbycVkbslFlDrVWsFy7yTLPy1Y36sjWm+hp6uLt356RR8vvCCeboldjeJpq4QyR/+AduWMfdJkBBAs2tZcMQVG8oee2w7mVMOYKTgPLCvkyVM0vOVe7l2+UUczGtSMJlZ7Vt9iwSV5XjgRwjmMQ3JdqvgMstCE0YZ7mDb8L6kvUG4bvkvPOdhoAokrgAKLNwADQJQ0/I+jjTLNq1qerhLYVtdJgR7Vuqq9z+kXXiP2GaXIevEuaJ5TJTnKYe0QZxYPWvo78tQbcnLnqIH8tRv8C4oQpLv6F4fclTGNasAahK9WLGD7c+0e+Je5uIyvUy86gKhPFdXFSlVM/lthmf5d8pBA8GEhv3KUTqgL46XYZYj7D885sVSL0ot+VqRmmWnmi2j1ZbMMKPdvgvrNe96A0mM+ZXcT7ep/loYZ1Tu6CmovSyNkXvlkP+7C/Mf1QoDg9St1SVAoLFg1TP++C09IWabi5w2E9qwvi6H9p8tpUg5Quk00Z+e2J+H6ZnVQV98pY+jKWBAGUCoWKeRWIoj0ZE6NgytZpJg8iHqPoWl+hdL8vysZ+lABTRcvYx0CP0E0TGXJM/C+1P5dIB6p9C7/wNi6B2ZdyFr/sLYTtQtfhhAPaQ0fvrsw0esOEw8f8JJKarJ8Yl/HJvnXjOyc4m0odsol5/fRf2frl4USiw94syc9SduImxhyrfBLF5pII9CJCPnhPkmCSr+9rRi/vXXQZyxsbLXBHG39Zb/bo+v2O33lsiHA7V7k8XWlRQQczfULjdeTvr/wi8tWaW5tefi9ywAiRzmPNVajIWFzB8+YW5o/F70W3AHy8t871KUMXlZSI32uiYxdzImbRAq9XMer7bZ8kb5usktAKePs/DCjYW+aean7XO47dBZ48qESAGnCa5okUK0M00Kw7Vtze61VM6T6XlZqQcTohpzf9WPqe+6rn35elrsx16yp6MW6ehmMqvVFZqyLdj1xcmlXLg/Q9O26RDVzObdkJqfzsp0svIn5BR/fa8pzAfLSj+0p/k67ZCdHLS9ZYY/oXQmLo11u6yEWnhnr3GSSDvj7pfIcAAm0btIMsW4oS3JSK688LXIVSHHDrPy9pbu9l94xIzyTXduj4u4GLqmh9kWicKOr9XsMHtvuDozGw5g6hUId8LDegQsknEkDBN1SoO5Oj9GMb07/4+kHPUmfLDSjg3QRUYkIFxMCvh0dRgKJPirKZLUMK6i9+yABsq04IAE/9NdkAxVhEU6iNRgF5fnJd1TEahe4Tc147tRd7MRRFBLKuOBUKjx5OrYAsQYtrRMxkAXdO2G9qe6D2BPXclzkOAA1ssD8ZQfH76fkSdgV0EAHoY/cKbdMCVeAYU2AT+QWEpIDjfM2cBH6IvxOgAEwz4wApSookACoChnMGcEHd1dwXo0O5NzcFGjAe3iA2qFel4dHdmggPQXFiXpOz3+yVL9zs0huL0UaJjSHnDtJKejgBQa56hYmMoFx6d9otmxSZY3J59AUngeljYJwPB9o7J8iG7g1ebnTZplhGpv6g4/fnNMDj3aJ7UPdhH5fDfuSVsGCVoKcZdyNL9sdu4uggoOtYnAV8xuRi3cduUAvTaaDPbEz9N7g/+46EmBlQ6BomgTmdHpk/etWHwLVJ1JDtcfQmi/yXCXYtfUmpxjDIfO/cddfm3QtlJvTQ8GAPkw07Sd6g39eiL7IFWv7CdWqwoS85yMBlAFnUxYZOMtXKMwjAA0U0FBUoQFE0Yv5ZH9iFQcPvERc5QPQGEA9GUHRhbDm4bcJOAaUPNhOf4LlXQZC43e52ARKjqIF06xQ4HnYHzILY78B6aoXcnYClGfAt7l3A5seV5ZfFgyNgbrDtWA6oGYt3p2MgZtmKXCTikysANaQmB/bdrjCMgAiGSQG4i60mQBXInyGBNkx8RM7m7spKvZ25zak+aZD7hsSR9Wttca16R9R0ew/eq1JtmhsuhDMgD7+db824TWkkaoYiF/9lkLG/I6kwOUh0DM/CM5WFP7gtS0GH9I0zdOg7o19lKbjSyGrzboCjZqcs55NCE6L2Vfc2MDD2S1rQ+9lAEzgPqxREjk0to8VY8XjEtJIa2vXzpY+ZkkEJFLcEv7XUJbrVPXcnjF9z0uzRIEp0B/VRFkEmKrkrcEOd+Ww8Aea3x2ohBNikVcxzwqRPHwwpD61GvV19/QNrfPJxj8cnzVWLwXWZAIlrNa1zNtmabDTYO27Yc4lQO27mdkUJDtdoZSRMln85Fff7sD7MD6xC5XUNuc3pB69x+sVTEl9is4jQxWkBllYd703SMqKMO0qHcVWhT7pt5b+byzWIA5x5OWb9U9GG0T5k7kPfcfrjIvFkjajPCRAGMRJQ3Uov5ObspsEWHebMhOKSHLoWn2snmzIrOzJUvr0Lq9fLhFUq+u4ht2qy/10u6URZdmQK/W3yY45psy6E47kojUZLSiFpVCLKvV2PAT3FskxQfRs+DSeqlUm3WnPVhPYb220duvB9+V7ODnkpYQKLStfIe6Da/AhHZBU7tGpfE9O4Q2dZNyWwcl+dk0YavvGLlRJIsKdj5PGRDUDjtIa/qxVRwtKzX3M8fX7e8CXf26NCWt/qx80SQl4RnK5YKpcUgrO1Ap6TpO/mWyKilcya9iOc1hbVvNlylgLpdGVOUIRi+84P7FSeUuim/rE0ptE1yQ73l4nsbaQ8qYhUFqlwTnwkpGHk6MyXpY9+8UIUpTltJSkl+fNBNnkqOKO3lmvKz5T06dcgcDDShcEGRlSGVo2tNGF1qAiJzYHXIOfb4JSKug6J5ALuhLzmOsoQli5aHmY55KlHu2UtpZXGTFjHxsfnmJ3FyObs7t+jf1axMn35e0O+EOEnlqQNU/g7xvcYlwW3mR+5SUwmEgEOIx5ZuWFCjdrxM9gutMlZtb0KSwyxxtJ57ClNqn31See7VRhRYmf9kexyhq5XY1o/SSzKtqMD+fCc2tv4fcQ1fhXcxYZfUfj56CecFtLodLqS3nwe+HOvQGJOmC5YL48z0jadfcxPvelrq7keIzrKq9p71j65JnQwJD3ng02VKg1d3WGlqULqeAdEloSzf9ZjGe1MghcHL5tAfy8qjiYnIUkwX5QyGzNkY7i6wDdn6QTQsJlv3ygPvEu/LGb5BqA1SxKXtLPsQD2R9MA1Fg3McHkf01jeBz0JvUz3zdJ5cGwmIzsaMPsWjaipEnWI6hueGG/CX/F914Ct4HEeapYRC0nMOHX7KHr9TcOC+gXoz8WjO8f7SJNircoxV9imxb/B/Ft2+CWEQYinKzXJPH+QVeydpUdC3HXYplGWdYoQQnM+DZf/cKe86ZT4w1+Dp3ydUHpDDmyE3NNu2AzNtcY2LGetS9IbfpMzmpe4vfVH+7FoVzLSlYbl78MYReFN/ESIi6gck8LSgkyXjvVjwnvL0P6AI6N31JaXhKo8/vfvMxFCDSPh5NdlX38NWf/jP/782x9//Nc/58hdvA1p9Y8sX9K5/q31OPxjGbdHvP81mv2XE/zPsfG/zH//Xf8aRRddvD6O5w/rvOV/+6+//TcFhIOFvhYAAA==" + } + } + ] + } +} \ No newline at end of file