diff --git a/config/memory_statistics.py b/config/memory_statistics.py new file mode 100644 index 0000000000..5205b4e89e --- /dev/null +++ b/config/memory_statistics.py @@ -0,0 +1,331 @@ +# Standard library imports +import syslog + +# Third-party imports +import click + +# Type hints +from typing import Tuple, Optional + +# Local imports +from swsscommon.swsscommon import ConfigDBConnector + +# Constants +MEMORY_STATISTICS_TABLE = "MEMORY_STATISTICS" +MEMORY_STATISTICS_KEY = "memory_statistics" + +SAMPLING_INTERVAL_MIN = 3 +SAMPLING_INTERVAL_MAX = 15 +RETENTION_PERIOD_MIN = 1 +RETENTION_PERIOD_MAX = 30 + +DEFAULT_SAMPLING_INTERVAL = 5 # minutes +DEFAULT_RETENTION_PERIOD = 15 # days + +syslog.openlog("memory_statistics_config", syslog.LOG_PID | syslog.LOG_CONS, syslog.LOG_USER) + + +def log_to_syslog(message: str, level: int = syslog.LOG_INFO) -> None: + """ + Logs a message to the system log (syslog) with error handling. + + This function logs the provided message to syslog at the specified level. + It handles potential errors such as system-related issues (OSError) and + invalid parameters (ValueError) by displaying appropriate error messages. + + Args: + message (str): The message to log. + level (int, optional): The log level (default is syslog.LOG_INFO). + """ + try: + syslog.syslog(level, message) + except OSError as e: + click.echo(f"System error while logging to syslog: {e}", err=True) + except ValueError as e: + click.echo(f"Invalid syslog parameters: {e}", err=True) + + +def generate_error_message(error_type: str, error: Exception) -> str: + """ + Generates a formatted error message for logging and user feedback. + + Args: + error_type (str): A short description of the error type. + error (Exception): The actual exception object. + + Returns: + str: A formatted error message string. + """ + return f"{error_type}: {error}" + + +def validate_range(value: int, min_val: int, max_val: int) -> bool: + """ + Validates whether a given integer value falls within a specified range. + + Args: + value (int): The value to validate. + min_val (int): The minimum allowable value. + max_val (int): The maximum allowable value. + + Returns: + bool: True if the value is within range, False otherwise. + """ + return min_val <= value <= max_val + + +class MemoryStatisticsDB: + """ + Singleton class for managing the connection to the memory statistics configuration database. + """ + _instance = None + _db = None + + def __new__(cls): + """ + Creates and returns a singleton instance of MemoryStatisticsDB. + + Returns: + MemoryStatisticsDB: The singleton instance. + """ + if cls._instance is None: + cls._instance = super(MemoryStatisticsDB, cls).__new__(cls) + cls._connect_db() + return cls._instance + + @classmethod + def _connect_db(cls): + """ + Establishes a connection to the ConfigDB database. + + Logs an error if the connection fails. + """ + try: + cls._db = ConfigDBConnector() + cls._db.connect() + except RuntimeError as e: + log_to_syslog(f"ConfigDB connection failed: {e}", syslog.LOG_ERR) + cls._db = None + + @classmethod + def get_db(cls): + """ + Retrieves the database connection instance, reconnecting if necessary. + + Returns: + ConfigDBConnector: The active database connection. + + Raises: + RuntimeError: If the database connection is unavailable. + """ + if cls._db is None: + cls._connect_db() + if cls._db is None: + raise RuntimeError("Database connection unavailable") + return cls._db + + +def update_memory_statistics_status(enabled: bool) -> Tuple[bool, Optional[str]]: + """ + Updates the enable/disable status of memory statistics in the configuration database. + + This function modifies the configuration database to enable or disable + memory statistics collection based on the provided status. It also logs + the action and returns a tuple indicating whether the operation was successful. + + Args: + enabled (bool): True to enable memory statistics, False to disable. + + Returns: + Tuple[bool, Optional[str]]: A tuple containing success status and an optional error message. + """ + try: + db = MemoryStatisticsDB.get_db() + + db.mod_entry(MEMORY_STATISTICS_TABLE, MEMORY_STATISTICS_KEY, {"enabled": str(enabled).lower()}) + msg = f"Memory statistics feature {'enabled' if enabled else 'disabled'} successfully." + click.echo(msg) + log_to_syslog(msg) + return True, None + except (KeyError, ConnectionError, RuntimeError) as e: + error_msg = generate_error_message(f"Failed to {'enable' if enabled else 'disable'} memory statistics", e) + + click.echo(error_msg, err=True) + log_to_syslog(error_msg, syslog.LOG_ERR) + return False, error_msg + + +@click.group(help="Tool to manage memory statistics configuration.") +def cli(): + """ + Memory statistics configuration tool. + + This command-line interface (CLI) allows users to configure and manage + memory statistics settings such as enabling/disabling the feature and + modifying parameters like the sampling interval and retention period. + """ + pass + + +@cli.group(help="Commands to configure system settings.") +def config(): + """ + Configuration commands for managing memory statistics. + + Example: + $ config memory-stats enable + $ config memory-stats sampling-interval 5 + """ + pass + + +@config.group(name='memory-stats', help="Manage memory statistics collection settings.") +def memory_stats(): + """Configure memory statistics collection and settings. + + This group contains commands to enable/disable memory statistics collection + and configure related parameters. + + Examples: + Enable memory statistics: + $ config memory-stats enable + + Set sampling interval to 5 minutes: + $ config memory-stats sampling-interval 5 + + Set retention period to 7 days: + $ config memory-stats retention-period 7 + + Disable memory statistics: + $ config memory-stats disable + """ + pass + + +@memory_stats.command(name='enable') +def memory_stats_enable(): + """Enable memory statistics collection. + + This command enables the collection of memory statistics on the device. + It updates the configuration and reminds the user to run 'config save' + to persist changes. + + Example: + $ config memory-stats enable + Memory statistics feature enabled successfully. + Reminder: Please run 'config save' to persist changes. + """ + success, error = update_memory_statistics_status(True) + if success: + click.echo("Reminder: Please run 'config save' to persist changes.") + log_to_syslog("Memory statistics enabled. Reminder to run 'config save'") + + +@memory_stats.command(name='disable') +def memory_stats_disable(): + """Disable memory statistics collection. + + This command disables the collection of memory statistics on the device. + It updates the configuration and reminds the user to run 'config save' + to persist changes. + + Example: + $ config memory-stats disable + Memory statistics feature disabled successfully. + Reminder: Please run 'config save' to persist changes. + """ + success, error = update_memory_statistics_status(False) + if success: + click.echo("Reminder: Please run 'config save' to persist changes.") + log_to_syslog("Memory statistics disabled. Reminder to run 'config save'") + + +@memory_stats.command(name='sampling-interval') +@click.argument("interval", type=int) +def memory_stats_sampling_interval(interval: int): + """ + Configure the sampling interval for memory statistics collection. + + This command updates the interval at which memory statistics are collected. + The interval must be between 3 and 15 minutes. + + Args: + interval (int): The desired sampling interval in minutes. + + Examples: + Set sampling interval to 5 minutes: + $ config memory-stats sampling-interval 5 + Sampling interval set to 5 minutes successfully. + Reminder: Please run 'config save' to persist changes. + + Invalid interval example: + $ config memory-stats sampling-interval 20 + Error: Sampling interval must be between 3 and 15 minutes. + """ + if not validate_range(interval, SAMPLING_INTERVAL_MIN, SAMPLING_INTERVAL_MAX): + error_msg = f"Error: Sampling interval must be between {SAMPLING_INTERVAL_MIN} and {SAMPLING_INTERVAL_MAX}." + click.echo(error_msg, err=True) + log_to_syslog(error_msg, syslog.LOG_ERR) + return + + try: + db = MemoryStatisticsDB.get_db() + db.mod_entry(MEMORY_STATISTICS_TABLE, MEMORY_STATISTICS_KEY, {"sampling_interval": str(interval)}) + success_msg = f"Sampling interval set to {interval} minutes successfully." + click.echo(success_msg) + log_to_syslog(success_msg) + click.echo("Reminder: Please run 'config save' to persist changes.") + except (KeyError, ConnectionError, ValueError, RuntimeError) as e: + error_msg = generate_error_message(f"{type(e).__name__} setting sampling interval", e) + click.echo(error_msg, err=True) + log_to_syslog(error_msg, syslog.LOG_ERR) + return + + +@memory_stats.command(name='retention-period') +@click.argument("period", type=int) +def memory_stats_retention_period(period: int): + """ + Configure the retention period for memory statistics storage. + + This command sets the number of days memory statistics should be retained. + The retention period must be between 1 and 30 days. + + Args: + period (int): The desired retention period in days. + + Examples: + Set retention period to 7 days: + $ config memory-stats retention-period 7 + Retention period set to 7 days successfully. + Reminder: Please run 'config save' to persist changes. + + Invalid period example: + $ config memory-stats retention-period 45 + Error: Retention period must be between 1 and 30 days. + """ + if not validate_range(period, RETENTION_PERIOD_MIN, RETENTION_PERIOD_MAX): + error_msg = f"Error: Retention period must be between {RETENTION_PERIOD_MIN} and {RETENTION_PERIOD_MAX}." + click.echo(error_msg, err=True) + log_to_syslog(error_msg, syslog.LOG_ERR) + return + + try: + db = MemoryStatisticsDB.get_db() + db.mod_entry(MEMORY_STATISTICS_TABLE, MEMORY_STATISTICS_KEY, {"retention_period": str(period)}) + success_msg = f"Retention period set to {period} days successfully." + click.echo(success_msg) + log_to_syslog(success_msg) + click.echo("Reminder: Please run 'config save' to persist changes.") + except (KeyError, ConnectionError, ValueError, RuntimeError) as e: + error_msg = generate_error_message(f"{type(e).__name__} setting retention period", e) + click.echo(error_msg, err=True) + log_to_syslog(error_msg, syslog.LOG_ERR) + return + + +if __name__ == "__main__": + try: + cli() + finally: + syslog.closelog() diff --git a/doc/Command-Reference.md b/doc/Command-Reference.md index a1c869c79a..6e294d0f76 100644 --- a/doc/Command-Reference.md +++ b/doc/Command-Reference.md @@ -228,7 +228,19 @@ * [Banner Commands](#banner-commands) * [Banner config commands](#banner-config-commands) * [Banner show command](#banner-show-command) - +* [Memory Statistics Commands](#memory-statistics-commands) + * [Overview](#overview) + * [Memory Statistics Config Commands](#memory-statistics-config-commands) + * [Enable/Disable Memory Statistics Monitoring](#enabledisable-memory-statistics-monitoring) + * [Set the Frequency of Memory Data Collection](#set-the-frequency-of-memory-data-collection) + * [Adjust the Data Retention Period](#adjust-the-data-retention-period) + * [Memory Statistics Show Commands](#memory-statistics-show-commands) + * [Default Historical Memory Statistics](#default-historical-memory-statistics) + * [Historical Memory Statistics for Last 10 Days](#historical-memory-statistics-for-last-10-days) + * [Historical Memory Statistics for Last 100 Minutes](#historical-memory-statistics-for-last-100-minutes) + * [Historical Memory Statistics for Last 3 Hours](#historical-memory-statistics-for-last-3-hours) + * [Historical Memory Statistics for Specific Metric (Used Memory)](#historical-memory-statistics-for-specific-metric-used-memory) + * [View Memory Statistics Configuration](#view-memory-statistics-configuration) ## Document History | Version | Modification Date | Details | @@ -13951,4 +13963,294 @@ enabled Login You are on All access and/or use are subject to monitoring. Help: https://sonic-net.github.io/SONiC/ + ``` +--- + +# Memory Statistics Commands + +## Overview +These commands allow users to enable/disable memory statistics monitoring, configure data collection intervals, adjust data retention periods, view memory statistics, and check the current configuration. Memory statistics can help administrators monitor and analyze system memory usage over time. + +**Common Use Cases** + - Monitor system memory trends over time. + - Track memory usage patterns during peak time. + - Plan system capacity based on historical memory data. + +--- + +## Memory Statistics Config Commands + +### Enable/Disable Memory Statistics Monitoring + +To enable or disable the memory statistics monitoring feature: + +```bash +admin@sonic:~$ config memory-stats enable/disable +``` + +This will **enable/disable** memory statistics monitoring. + +By default, this feature is **disabled**. + +**Examples**: + +- To enable memory statistics monitoring: + + ```bash + admin@sonic:~$ config memory-stats enable + Memory statistics monitoring enabled. + ``` + +- To disable memory statistics monitoring: + + ```bash + admin@sonic:~$ config memory-stats disable + Memory statistics monitoring disabled. + ``` + +--- + +### Set the Frequency of Memory Data Collection + +To configure the interval for memory data collection (specified in minutes): + +```bash +admin@sonic:~$ config memory-stats sampling-interval +``` + +- `` is the sampling interval in minutes. +- The default sampling interval is **5 minutes**. + +**Example**: + +- To set the sampling interval to 10 minutes: + + ```bash + admin@sonic:~$ config memory-stats sampling-interval 10 + Sampling interval set to 10 minutes. + ``` + +--- + +### Adjust the Data Retention Period + +To set how long the memory data should be retained (specified in days): + +```bash +admin@sonic:~$ config memory-stats retention-period +``` + +- `` is the retention period in days. +- The default retention period is **15 days**. + +**Example**: + +- To set the retention period to 30 days: + + ```bash + admin@sonic:~$ config memory-stats retention-period 30 + Retention period set to 30 days. + ``` + +--- + +## Memory Statistics Show Commands + +### View Memory Usage Statistics +To display memory usage statistics, use the following command with optional parameters for time range and specific metrics: + +```bash +admin@sonic:~$ show memory-stats [--from ] [--to ] [--select ] +``` + +**Command Options:** +- `show memory-stats`: Display basic memory usage statistics. +- `--from `: Display memory statistics from the specified start date-time. +- `--to `: Display memory statistics up to the specified end date-time. +- `--select `: Display specific memory statistics, such as total memory. + +**Time Format for Statistics Retrieval:** +The time format for `--from` and `--to` options includes: +- **Relative time formats:** + - 'X days ago', 'X hours ago', 'X minutes ago' + - 'yesterday', 'today' +- **Specific times and dates:** + - 'now' + - 'July 23', 'July 23, 2024', '2 November 2024' + - '7/24', '1/2' +- **Time expressions:** + - '2 am', '3:15 pm' + - 'Aug 01 06:43:40', 'July 1 3:00:00' +- **Named months:** + - 'jan', 'feb', 'march', 'september', etc. + - Full month names: 'January', 'February', 'March', etc. +- **ISO 8601 format:** + - '2024-07-01T15:00:00' + +--- + +### Default Historical Memory Statistics + +To view the historical memory statistics: + +```bash +admin@sonic:~$ show memory-stats +``` + +**Sample Output**: + +```bash +Memory Statistics: +Codes: M - minutes, H - hours, D - days +-------------------------------------------------------------------------------- +Report Generated: 2024-12-04 15:49:52 +Analysis Period: From 2024-11-19 15:49:52 to 2024-12-04 15:49:52 +Interval: 2 Days +-------------------------------------------------------------------------------------------------------------------------------------------------- +Metric Current High Low D19-D21 D21-D23 D23-D25 D25-D27 D27-D29 D29-D01 D01-D03 D03-D05 + Value Value Value 19Nov24 21Nov24 23Nov24 25Nov24 27Nov24 29Nov24 01Dec24 03Dec24 +-------------------------------------------------------------------------------------------------------------------------------------------------- +total_memory 15.29GB 15.29GB 15.29GB 15.29GB 15.29GB 15.29GB 15.29GB 15.29GB 15.29GB 15.29GB 15.29GB +used_memory 8.87GB 9.35GB 8.15GB 8.15GB 9.10GB 8.15GB 8.20GB 9.05GB 8.30GB 9.35GB 9.12GB +free_memory 943.92MB 906.28MB 500.00MB 800.00MB 750.00MB 906.2MB 650.00MB 600.00MB 550.00MB 500.00MB 725.92MB +available_memory 4.78GB 4.74GB 4.35GB 4.65GB 4.60GB 4.55GB 4.74GB 4.45GB 4.40GB 4.35GB 4.57GB +cached_memory 5.17GB 5.08GB 4.96GB 5.08GB 5.06GB 5.04GB 5.02GB 5.00GB 4.98GB 4.96GB 5.05GB +buffers_memory 337.83MB 333.59MB 295.00MB 325.00MB 320.00MB 315.00MB 333.59MB 305.00MB 300.00MB 295.00MB 317.84MB +shared_memory 1.31GB 1.22GB 1.08GB 1.22GB 1.20GB 1.18GB 1.15GB 1.12GB 1.10GB 1.08GB 1.19GB +``` + +--- + +### Historical Memory Statistics for Last 10 Days + +To view memory statistics for the last 10 days: + +```bash +admin@sonic:~$ show memory-stats --from '10 days ago' --to 'now' +``` + +**Sample Output**: + +``` +Memory Statistics: +Codes: M - minutes, H - hours, D - days +-------------------------------------------------------------------------------- +Report Generated: 2024-12-24 17:29:19 +Analysis Period: From 2024-12-14 17:29:19 to 2024-12-24 17:29:19 +Interval: 1 Days +-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +Metric Current High Low D14-D15 D15-D16 D16-D17 D17-D18 D18-D19 D19-D20 D20-D21 D21-D22 D22-D23 D23-D24 D24-D25 + Value Value Value 14Dec24 15Dec24 16Dec24 17Dec24 18Dec24 19Dec24 20Dec24 21Dec24 22Dec24 23Dec24 24Dec24 +-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +total_memory 15.29GB 15.29GB 15.29GB - - - - - - - - - - 15.29GB +used_memory 11.74GB 9.14GB 9.14GB - - - - - - - - - - 9.14GB +free_memory 704.33MB 2.61GB 2.61GB - - - - - - - - - - 2.61GB +available_memory 2.21GB 4.73GB 4.73GB - - - - - - - - - - 4.73GB +cached_memory 2.76GB 3.40GB 3.40GB - - - - - - - - - - 3.40GB +buffers_memory 105.39MB 144.28MB 144.28MB - - - - - - - - - - 144.28MB +shared_memory 1.00GB 1.08GB 1.08GB - - - - - - - - - - 1.08GB +``` + +--- + +### Historical Memory Statistics for Last 100 Minutes + +```bash +admin@sonic:~$ show memory-stats --from '100 minutes ago' --to 'now' +``` + +**Sample Output**: + +``` +Memory Statistics: +Codes: M - minutes, H - hours, D - days +-------------------------------------------------------------------------------- +Report Generated: 2024-12-24 17:24:08 +Analysis Period: From 2024-12-24 15:44:08 to 2024-12-24 17:24:08 +Interval: 10 Minutes +-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +Metric Current High Low M44-M54 M54-M04 M04-M14 M14-M24 M24-M34 M34-M44 M44-M54 M54-M04 M04-M14 M14-M24 M24-M34 + Value Value Value 15:44 15:54 16:04 16:14 16:24 16:34 16:44 16:54 17:04 17:14 17:24 +-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +total_memory 15.29GB 15.29GB 15.29GB 15.29GB 15.29GB 15.29GB 15.29GB 15.29GB 15.29GB 15.29GB 15.29GB 15.29GB 15.29GB - +used_memory 11.62GB 11.81GB 10.69GB 11.81GB 11.74GB 10.69GB 10.93GB 11.31GB 11.31GB 11.38GB 11.40GB 11.44GB 11.50GB - +free_memory 888.46MB 1.65GB 514.18MB 514.18MB 525.77MB 1.65GB 1.15GB 802.98MB 818.78MB 680.81MB 716.42MB 533.82MB 1.07GB - +available_memory 2.35GB 3.37GB 2.25GB 2.25GB 2.25GB 3.37GB 2.96GB 2.62GB 2.64GB 2.52GB 2.57GB 2.49GB 2.52GB - +cached_memory 2.70GB 3.15GB 2.63GB 2.85GB 2.91GB 2.82GB 3.07GB 3.05GB 3.03GB 3.09GB 3.03GB 3.15GB 2.63GB - +buffers_memory 101.39MB 186.47MB 99.00MB 134.77MB 136.97MB 140.94MB 148.42MB 153.82MB 157.19MB 160.90MB 165.18MB 186.47MB 99.00MB - +shared_memory 1005.79MB 1.07GB 917.46MB 926.08MB 993.94MB 917.46MB 1.07GB 1.01GB 1020.12MB 1.04GB 1001.18MB 1.01GB 961.13MB - +``` + +--- + +### Historical Memory Statistics for Last 3 Hours + +```bash +admin@sonic:~$ show memory-stats --from '3 hours ago' --to 'now' +``` + +**Sample Output**: + +``` +Memory Statistics: +Codes: M - minutes, H - hours, D - days +-------------------------------------------------------------------------------- +Report Generated: 2024-12-24 17:24:51 +Analysis Period: From 2024-12-24 14:24:51 to 2024-12-24 17:24:51 +Interval: 1 Hours +-------------------------------------------------------------------------------------------------- +Metric Current High Low H14-H15 H15-H16 H16-H17 H17-H18 + Value Value Value 14:24 15:24 16:24 17:24 +-------------------------------------------------------------------------------------------------- +total_memory 15.29GB 15.29GB 15.29GB 15.29GB 15.29GB 15.29GB - +used_memory 11.59GB 11.52GB 11.39GB 11.42GB 11.52GB 11.39GB - +free_memory 928.18MB 826.58MB 774.48MB 780.43MB 826.58MB 774.48MB - +available_memory 2.39GB 2.56GB 2.50GB 2.53GB 2.50GB 2.56GB - +cached_memory 2.70GB 3.00GB 2.83GB 2.89GB 2.83GB 3.00GB - +buffers_memory 101.62MB 153.76MB 132.42MB 149.62MB 132.42MB 153.76MB - +shared_memory 997.97MB 1020.80MB 961.19MB 971.47MB 961.19MB 1020.80MB - +``` + +--- + +### Historical Memory Statistics for Specific Metric (Used Memory) + +```bash +admin@sonic:~$ show memory-stats --from '100 minutes ago' --to 'now' --select 'used_memory' +``` + +**Sample Output**: + +``` +Memory Statistics: +Codes: M - minutes, H - hours, D - days +-------------------------------------------------------------------------------- +Report Generated: 2024-12-24 17:27:58 +Analysis Period: From 2024-12-24 15:47:58 to 2024-12-24 17:27:58 +Interval: 10 Minutes +-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +Metric Current High Low M47-M57 M57-M07 M07-M17 M17-M27 M27-M37 M37-M47 M47-M57 M57-M07 M07-M17 M17-M27 M27-M37 + Value Value Value 15:47 15:57 16:07 16:17 16:27 16:37 16:47 16:57 17:07 17:17 17:27 +-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +used_memory 11.69GB 11.79GB 10.55GB 11.79GB 11.35GB 10.55GB 11.24GB 11.30GB 11.33GB 11.40GB 11.39GB 11.46GB 11.62GB - + + +``` + +--- + +### View Memory Statistics Configuration +To display the current configuration parameters such as data collection frequency, retention period, and enable/disable status, use the following command: + +```bash +admin@sonic:~$ show memory-stats-config +``` +**Example:** +```bash +admin@sonic:~$ show memory-stats-config +Memory Statistics Configuration: +-------------------------------- +Enabled: false +Sampling Interval: 5 +Retention Period: 15 ``` diff --git a/show/memory_statistics.py b/show/memory_statistics.py new file mode 100644 index 0000000000..26b24e6407 --- /dev/null +++ b/show/memory_statistics.py @@ -0,0 +1,657 @@ +# Standard library imports +import json +import os +import signal +import socket +import sys +import syslog +import time +from typing import Any, Dict, Union + +# Third-party library imports +import click +from dataclasses import dataclass + +# Local imports +from swsscommon.swsscommon import ConfigDBConnector + + +@dataclass +class Config: + """ + Configuration class to manage the settings for memory statistics service and socket. + + Attributes: + SOCKET_PATH (str): The path to the Unix domain socket for communication. + SOCKET_TIMEOUT (int): The timeout value in seconds for socket operations. + BUFFER_SIZE (int): The buffer size for socket communication. + MAX_RETRIES (int): Maximum number of retry attempts for socket connection. + RETRY_DELAY (float): Delay in seconds between retry attempts. + DEFAULT_CONFIG (dict): Default configuration for memory statistics, including enabled status, + sampling interval, and retention period. + """ + + SOCKET_PATH: str = '/var/run/dbus/memstats.socket' + SOCKET_TIMEOUT: int = 30 + BUFFER_SIZE: int = 8192 + MAX_RETRIES: int = 3 + RETRY_DELAY: float = 1.0 + + DEFAULT_CONFIG = { + "enabled": "false", + "sampling_interval": "5", + "retention_period": "15" + } + + +class ConnectionError(Exception): + """ + Custom exception raised for connection-related errors in the system. + + This exception is used to signal issues with network connections or socket communications. + """ + pass + + +class DatabaseError(Exception): + """ + Custom exception raised for errors related to database interactions. + + This exception is used to signal issues with retrieving or updating data in the SONiC configuration database. + """ + pass + + +class Dict2Obj: + """ + A utility class that converts dictionaries or lists into objects, providing attribute-style access. + + This class is helpful when you need to convert JSON-like data (dictionaries or lists) into Python objects + with attributes that can be accessed using dot notation, and vice versa. + + Methods: + __init__(d): Initializes the object either from a dictionary or list. + _init_from_dict(d): Initializes the object from a dictionary. + _init_from_list(d): Initializes the object from a list. + to_dict(): Converts the object back to a dictionary format. + __repr__(): Returns a string representation of the object for debugging purposes. + """ + + def __init__(self, d: Union[Dict[str, Any], list]) -> None: + if isinstance(d, dict): + self._init_from_dict(d) + elif isinstance(d, list): + self._init_from_list(d) + else: + raise ValueError("Input should be a dictionary or a list") + + def _init_from_dict(self, d: Dict[str, Any]) -> None: + for key, value in d.items(): + if isinstance(value, (list, tuple)): + setattr(self, key, [Dict2Obj(x) if isinstance(x, dict) else x for x in value]) + else: + setattr(self, key, Dict2Obj(value) if isinstance(value, dict) else value) + + def _init_from_list(self, d: list) -> None: + self.items = [Dict2Obj(x) if isinstance(x, dict) else x for x in d] + + def to_dict(self) -> Dict[str, Any]: + """Converts the object back to a dictionary format.""" + if hasattr(self, "items"): + return [x.to_dict() if isinstance(x, Dict2Obj) else x for x in self.items] + + result = {} + for key in self.__dict__: + value = getattr(self, key) + if isinstance(value, Dict2Obj): + result[key] = value.to_dict() + elif isinstance(value, list): + result[key] = [v.to_dict() if isinstance(v, Dict2Obj) else v for v in value] + else: + result[key] = value + return result + + def __repr__(self) -> str: + """Provides a string representation of the object for debugging.""" + return f"<{self.__class__.__name__} {self.to_dict()}>" + + +class SonicDBConnector: + """ + A class that handles interactions with the SONiC configuration database, + including connection retries and error handling. + + This class ensures robust connection management by retrying failed attempts to connect to the database + and provides methods for fetching memory statistics configuration. + + Methods: + __init__(): Initializes the connector and attempts to connect to the database. + _connect_with_retry(): Attempts to connect to the database with a retry mechanism. + get_memory_statistics_config(): Retrieves the memory statistics configuration from the database. + """ + + def __init__(self) -> None: + self.config_db = ConfigDBConnector() + self._connect_with_retry() + + def _connect_with_retry(self, max_retries: int = 3, retry_delay: float = 1.0) -> None: + """ + Attempts to connect to the SONiC configuration database with a retry mechanism. + + This function will attempt to connect to the database up to `max_retries` times, + with a delay of `retry_delay` seconds between each attempt. If the connection + fails after all retries, a `ConnectionError` is raised. + + Args: + max_retries (int): Maximum number of retries before raising a `ConnectionError` (default is 3). + retry_delay (float): Delay in seconds between retries (default is 1.0). + + Raises: + ConnectionError: If the connection to the database fails after all retries. + """ + for attempt in range(max_retries): + try: + self.config_db.connect() + syslog.syslog(syslog.LOG_INFO, "Successfully connected to SONiC config database") + return + except (socket.error, IOError) as e: + if attempt < max_retries - 1: + syslog.syslog( + syslog.LOG_WARNING, + f"Failed to connect to database (attempt {attempt + 1}/{max_retries}): {str(e)}" + ) + time.sleep(retry_delay) + else: + raise ConnectionError( + f"Failed to connect to SONiC config database after {max_retries} attempts: {str(e)}" + ) from e + + def get_memory_statistics_config(self) -> Dict[str, str]: + """ + Retrieves the memory statistics configuration from the SONiC configuration database. + + This function fetches the memory statistics configuration from the database, providing + default values if not found or if there are any errors. It handles potential errors and logs them. + + Returns: + Dict[str, str]: The memory statistics configuration as a dictionary. + + Raises: + DatabaseError: If there is an error while retrieving the configuration from the database. + """ + try: + config = self.config_db.get_table('MEMORY_STATISTICS') + if not isinstance(config, dict) or 'memory_statistics' not in config: + return Config.DEFAULT_CONFIG.copy() + + current_config = config.get('memory_statistics', {}) + if not isinstance(current_config, dict): + return Config.DEFAULT_CONFIG.copy() + + result_config = Config.DEFAULT_CONFIG.copy() + for key, value in current_config.items(): + if value is not None and value != "": + result_config[key] = value + + return result_config + + except (KeyError, ValueError) as e: + error_msg = f"Error retrieving memory statistics configuration: {str(e)}" + syslog.syslog(syslog.LOG_ERR, error_msg) + raise DatabaseError(error_msg) from e + + +class SocketManager: + """ + A class that manages Unix domain socket connections for communication with the memory statistics service. + + This class ensures proper socket validation, connection retries, and error handling, while maintaining secure + socket file permissions and ownership. + + Methods: + __init__(socket_path): Initializes the socket manager and validates the socket file. + _validate_socket_path(create_if_missing): Validates the socket file path, checking permissions and ownership. + connect(): Establishes a connection to the memory statistics service via Unix domain socket. + receive_all(expected_size, max_attempts): Receives all data from the socket with error handling. + send(data): Sends data to the socket. + close(): Closes the socket connection safely. + """ + + def __init__(self, socket_path: str = Config.SOCKET_PATH): + self.socket_path = socket_path + self.sock = None + self._validate_socket_path(create_if_missing=True) + + def _validate_socket_path(self, create_if_missing: bool = False) -> None: + """ + Validates the socket file path and checks for the necessary permissions and ownership. + + This function checks if the socket file exists and has the correct permissions (0o600), + and that it is owned by root. If the file does not exist and `create_if_missing` is `True`, + the socket file is created. If the file exists with incorrect permissions, a `PermissionError` is raised. + + Args: + create_if_missing (bool): Whether to create the socket file if it does not exist (default is False). + + Raises: + ConnectionError: If the socket directory or file is missing or not accessible. + PermissionError: If the socket file has incorrect permissions or ownership. + """ + socket_dir = os.path.dirname(self.socket_path) + if not os.path.exists(socket_dir): + raise ConnectionError(f"Socket directory {socket_dir} does not exist") + + if not os.path.exists(self.socket_path): + if create_if_missing: + syslog.syslog(syslog.LOG_INFO, f"Socket file {self.socket_path} does not exist, creating it.") + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.bind(self.socket_path) + os.chmod(self.socket_path, 0o600) + return + else: + raise ConnectionError(f"Socket file {self.socket_path} not found") + + try: + socket_stat = os.stat(self.socket_path) + permissions = oct(socket_stat.st_mode & 0o777) + syslog.syslog(syslog.LOG_INFO, f"Socket permissions: {permissions}") + + if socket_stat.st_mode & 0o777 != 0o600: + raise PermissionError(f"Insecure socket file permissions: {permissions}") + + if socket_stat.st_uid != 0: + raise PermissionError(f"Socket not owned by root: {self.socket_path}") + + except FileNotFoundError: + raise ConnectionError(f"Socket file {self.socket_path} not found") + + def connect(self) -> None: + """ + Establishes a Unix domain socket connection with improved error handling. + + This function attempts to establish a connection to the memory statistics service via + a Unix domain socket. It will retry the connection up to `Config.MAX_RETRIES` times with + a delay of `Config.RETRY_DELAY` seconds between attempts. + + Raises: + ConnectionError: If the connection fails after the maximum number of retries. + """ + retries = 0 + + syslog.syslog(syslog.LOG_INFO, f"Attempting socket connection from PID {os.getpid()}") + + while retries < Config.MAX_RETRIES: + try: + if self.sock: + self.close() + + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.settimeout(Config.SOCKET_TIMEOUT) + self.sock.connect(self.socket_path) + syslog.syslog(syslog.LOG_INFO, "Successfully connected to memory statistics service") + return + except socket.error as e: + retries += 1 + if retries < Config.MAX_RETRIES: + syslog.syslog( + syslog.LOG_WARNING, + f"Socket connection attempt {retries} failed: {str(e)}" + ) + time.sleep(Config.RETRY_DELAY) + else: + raise ConnectionError( + f"Failed to connect to memory statistics service after {Config.MAX_RETRIES} attempts: {str(e)}" + ) from e + + def receive_all(self, expected_size: int, max_attempts: int = 100) -> str: + """ + Receives all data from the socket until the expected size is reached. + + This function ensures that the complete data is received from the socket. If the expected + size is not reached within the specified number of attempts, it raises an exception. It also + handles timeout and socket errors. + + Args: + expected_size (int): The expected size of the data to receive. + max_attempts (int): Maximum number of attempts to receive the data (default is 100). + + Returns: + str: The received data as a string. + + Raises: + ConnectionError: If the socket operation times out or encounters an error. + """ + if not self.sock: + raise ConnectionError("No active socket connection") + + data = b"" + attempts = 0 + + while len(data) < expected_size and attempts < max_attempts: + try: + chunk = self.sock.recv(expected_size - len(data)) + if not chunk: + break + data += chunk + except socket.timeout as e: + error_msg = f"Socket operation timed out after {Config.SOCKET_TIMEOUT} seconds" + syslog.syslog(syslog.LOG_ERR, error_msg) + raise ConnectionError(error_msg) from e + except socket.error as e: + error_msg = f"Socket error during receive: {str(e)}" + syslog.syslog(syslog.LOG_ERR, error_msg) + raise ConnectionError(error_msg) from e + + attempts += 1 + + if len(data) < expected_size: + syslog.syslog(syslog.LOG_WARNING, "Received incomplete data, possible socket issue.") + + try: + return data.decode('utf-8') + except UnicodeDecodeError as e: + error_msg = f"Failed to decode received data: {str(e)}" + syslog.syslog(syslog.LOG_ERR, error_msg) + raise ConnectionError(error_msg) from e + + def send(self, data: str) -> None: + """ + Sends data over the socket with improved error handling. + + This function sends data through the socket. It raises a `ConnectionError` if the data + cannot be sent due to socket issues. + + Args: + data (str): The data to send. + + Raises: + ConnectionError: If there is an error while sending the data. + """ + if not self.sock: + raise ConnectionError("No active socket connection") + + try: + self.sock.sendall(data.encode('utf-8')) + except socket.error as e: + error_msg = f"Failed to send data: {str(e)}" + syslog.syslog(syslog.LOG_ERR, error_msg) + raise ConnectionError(error_msg) from e + + def close(self) -> None: + """ + Closes the socket connection safely. + + This function safely closes the socket connection and ensures that the socket is properly cleaned up. + + Raises: + Exception: If there is an error closing the socket. + """ + if self.sock: + try: + self.sock.close() + except Exception as e: + syslog.syslog(syslog.LOG_WARNING, f"Error closing socket: {str(e)}") + finally: + self.sock = None + + +class ResourceManager: + """ + A class that manages the cleanup of resources during shutdown. + + This class ensures that the necessary resources, including database connectors and socket connections, are + properly cleaned up to avoid resource leaks and maintain system integrity. + + Methods: + __init__(): Initializes the resource manager. + cleanup(): Performs cleanup of resources, including database connectors and socket managers. + """ + + def __init__(self): + self.db_connector = None + self.socket_manager = None + + def cleanup(self): + """ + Performs cleanup of resources during shutdown. + + This function cleans up all resources, including database connectors and socket connections, + ensuring proper shutdown. + + Raises: + None + """ + if self.db_connector: + del self.db_connector + if self.socket_manager: + self.socket_manager.close() + syslog.syslog(syslog.LOG_INFO, "Successfully cleaned up resources during shutdown") + + +def send_data(command: str, data: Dict[str, Any], quiet: bool = False) -> Dict2Obj: + """Sends a command and data to the memory statistics service. + + Time format for statistics retrieval are given below. + - Relative time formats: + - 'X days ago', 'X hours ago', 'X minutes ago' + - 'yesterday', 'today' + - Specific times and dates: + - 'now' + - 'July 23', 'July 23, 2024', '2 November 2024' + - '7/24', '1/2' + - Time expressions: + - '2 am', '3:15 pm' + - 'Aug 01 06:43:40', 'July 1 3:00:00' + - Named months: + - 'jan', 'feb', 'march', 'september', etc. + - Full month names: 'January', 'February', 'March', etc. + - ISO 8601 format (e.g., '2024-07-01T15:00:00') + + Args: + command (str): The command to send to the service. + data (Dict[str, Any]): The data payload to send with the command. + quiet (bool): If True, suppresses error messages. Defaults to False. + + Returns: + Dict2Obj: The response from the service as an object. + + Raises: + ConnectionError: If there is an issue with the socket connection. + ValueError: If the response cannot be parsed or is invalid. + DatabaseError: If the service returns an error status. + """ + socket_manager = SocketManager() + + try: + socket_manager.connect() + request = {"command": command, "data": data} + socket_manager.send(json.dumps(request)) + + response = socket_manager.receive_all(expected_size=Config.BUFFER_SIZE) + if not response: + raise ConnectionError("No response received from memory statistics service") + + try: + jdata = json.loads(response) + except json.JSONDecodeError as e: + error_msg = f"Failed to parse server response: {str(e)}" + syslog.syslog(syslog.LOG_ERR, error_msg) + raise ValueError(error_msg) from e + + if not isinstance(jdata, dict): + raise ValueError("Invalid response format from server") + + response_obj = Dict2Obj(jdata) + if not getattr(response_obj, 'status', True): + error_msg = getattr(response_obj, 'msg', 'Unknown error occurred') + raise DatabaseError(error_msg) from None + + return response_obj + + except Exception as e: + if not quiet: + click.echo(f"Error: {str(e)}", err=True) + raise + finally: + socket_manager.close() + + +def format_field_value(field_name: str, value: str) -> str: + """Formats configuration field values for display. + + Args: + field_name (str): The name of the configuration field. + value (str): The value of the configuration field. + + Returns: + str: The formatted value for display. + """ + if field_name == "enabled": + return "True" if value.lower() == "true" else "False" + return value if value != "Unknown" else "Not configured" + + +def display_config(db_connector: SonicDBConnector) -> None: + """Displays memory statistics configuration. + + Args: + db_connector (SonicDBConnector): The database connector to retrieve configuration. + + Raises: + click.ClickException: If there is an error retrieving the configuration. + """ + try: + config = db_connector.get_memory_statistics_config() + enabled = format_field_value("enabled", config.get("enabled", "Unknown")) + retention = format_field_value("retention_period", config.get("retention_period", "Unknown")) + sampling = format_field_value("sampling_interval", config.get("sampling_interval", "Unknown")) + + click.echo(f"{'Configuration Field':<30}{'Value'}") + click.echo("-" * 50) + click.echo(f"{'Enabled':<30}{enabled}") + click.echo(f"{'Retention Time (days)':<30}{retention}") + click.echo(f"{'Sampling Interval (minutes)':<30}{sampling}") + except Exception as e: + error_msg = f"Failed to retrieve configuration: {str(e)}" + syslog.syslog(syslog.LOG_ERR, error_msg) + raise click.ClickException(error_msg) + + +@click.group() +def cli(): + """Main entry point for the SONiC Memory Statistics CLI.""" + pass + + +@cli.group() +def show(): + """Show commands for memory statistics.""" + pass + + +@show.command(name="memory-stats") +@click.option( + '--from', 'from_time', + help='Start time for memory statistics (e.g., "15 hours ago", "7 days ago", "ISO Format")' +) +@click.option( + '--to', 'to_time', + help='End time for memory statistics (e.g., "now", "ISO Format")' +) +@click.option( + '--select', 'select_metric', + help='Show statistics for specific metric (e.g., total_memory, used_memory)' +) +def show_statistics(from_time: str, to_time: str, select_metric: str): + """Display memory statistics. + + Args: + from_time (str): Start time for memory statistics (e.g., "15 hours ago", "7 days ago", "ISO Format"). + to_time (str): End time for memory statistics (e.g., "now", "ISO Format"). + select_metric (str): Specific metric to show statistics for (e.g., total_memory, used_memory). + + Raises: + Exception: If there is an error retrieving or displaying the statistics. + """ + try: + request_data = { + "type": "system", + "metric_name": select_metric, + "from": from_time, + "to": to_time + } + + response = send_data("memory_statistics_command_request_handler", request_data) + stats_data = response.to_dict() + + if isinstance(stats_data, dict): + memory_stats = stats_data.get("data", "") + if memory_stats: + cleaned_output = memory_stats.replace("\n", "\n").strip() + click.echo(f"Memory Statistics:\n{cleaned_output}") + else: + click.echo("No memory statistics data available.") + else: + click.echo("Error: Invalid data format received") + + except Exception as e: + click.echo(f"Error: {str(e)}", err=True) + sys.exit(1) + + +@show.command(name="memory-stats-config") +def show_configuration(): + """Display memory statistics configuration. + + Raises: + Exception: If there is an error retrieving or displaying the configuration. + """ + try: + db_connector = SonicDBConnector() + display_config(db_connector) + except Exception as e: + click.echo(f"Error: {str(e)}", err=True) + sys.exit(1) + + +def shutdown_handler(signum: int, frame, resource_manager: ResourceManager) -> None: + """Signal handler for graceful shutdown. + + Args: + signum (int): Signal number. + frame: Current stack frame. + resource_manager (ResourceManager): ResourceManager instance for cleanup. + + Raises: + Exception: If there is an error during shutdown. + """ + try: + syslog.syslog(syslog.LOG_INFO, "Received SIGTERM signal, initiating graceful shutdown...") + resource_manager.cleanup() + click.echo("\nShutting down gracefully...") + sys.exit(0) + except Exception as e: + syslog.syslog(syslog.LOG_ERR, f"Error during shutdown: {str(e)}") + sys.exit(1) + + +def main(): + """Main entry point with enhanced error handling and shutdown management. + + Raises: + Exception: If there is a fatal error during execution. + """ + resource_manager = ResourceManager() + + try: + signal.signal(signal.SIGTERM, lambda signum, frame: shutdown_handler(signum, frame, resource_manager)) + cli() + except Exception as e: + syslog.syslog(syslog.LOG_ERR, f"Fatal error in main: {str(e)}") + click.echo(f"Error: {str(e)}", err=True) + resource_manager.cleanup() + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/tests/config_memory_statistics_test.py b/tests/config_memory_statistics_test.py new file mode 100644 index 0000000000..a9a2a8fccf --- /dev/null +++ b/tests/config_memory_statistics_test.py @@ -0,0 +1,345 @@ +# Standard library imports +import os +import subprocess +import syslog + +# Third-party imports +import pytest +from click.testing import CliRunner + +# Local imports +from config.memory_statistics import ( + cli, + log_to_syslog, + MemoryStatisticsDB, + MEMORY_STATISTICS_KEY, + MEMORY_STATISTICS_TABLE, + RETENTION_PERIOD_MAX, + RETENTION_PERIOD_MIN, + SAMPLING_INTERVAL_MAX, + SAMPLING_INTERVAL_MIN, + update_memory_statistics_status, +) + +# Testing utilities +from unittest.mock import Mock, patch + + +@pytest.fixture +def mock_db(): + """Fixture to create a mock database connection.""" + with patch('config.memory_statistics.ConfigDBConnector') as mock_db_class: + mock_db_instance = Mock() + mock_db_class.return_value = mock_db_instance + MemoryStatisticsDB._instance = None + MemoryStatisticsDB._db = None + yield mock_db_instance + + +@pytest.fixture +def cli_runner(): + """Fixture to create a CLI runner.""" + return CliRunner() + + +class TestMemoryStatisticsDB: + """Tests for the MemoryStatisticsDB singleton class""" + + def test_singleton_pattern(self, mock_db): + """Test that MemoryStatisticsDB implements singleton pattern correctly.""" + MemoryStatisticsDB._instance = None + MemoryStatisticsDB._db = None + + db1 = MemoryStatisticsDB() + db2 = MemoryStatisticsDB() + assert db1 is db2 + assert MemoryStatisticsDB._instance is db1 + mock_db.connect.assert_called_once() + + def test_get_db_connection(self, mock_db): + """Test that get_db returns the same database connection.""" + MemoryStatisticsDB._instance = None + MemoryStatisticsDB._db = None + + db1 = MemoryStatisticsDB.get_db() + db2 = MemoryStatisticsDB.get_db() + + assert db1 is db2 + mock_db.connect.assert_called_once() + + def test_connect_db_failure(self, mock_db): + """Test handling of database connection failure.""" + mock_db.connect.side_effect = RuntimeError("Connection failed") + MemoryStatisticsDB._instance = None + MemoryStatisticsDB._db = None + + with pytest.raises(RuntimeError, match="Database connection unavailable"): + MemoryStatisticsDB.get_db() + + +class TestUpdateMemoryStatisticsStatus: + """Tests for update_memory_statistics_status function""" + + def test_successful_enable(self, mock_db): + """Test successful status update to enable.""" + success, error = update_memory_statistics_status(True) + assert success is True + assert error is None + mock_db.mod_entry.assert_called_once_with( + MEMORY_STATISTICS_TABLE, + MEMORY_STATISTICS_KEY, + {"enabled": "true"} + ) + + def test_successful_disable(self, mock_db): + """Test successful status update to disable.""" + success, error = update_memory_statistics_status(False) + assert success is True + assert error is None + mock_db.mod_entry.assert_called_once_with( + MEMORY_STATISTICS_TABLE, + MEMORY_STATISTICS_KEY, + {"enabled": "false"} + ) + + def test_specific_exceptions(self, mock_db): + """Test handling of specific exceptions.""" + for exception in [KeyError, ConnectionError, RuntimeError]: + mock_db.mod_entry.side_effect = exception("Specific error") + success, error = update_memory_statistics_status(True) + assert success is False + assert "Specific error" in error + + +class TestMemoryStatisticsEnable: + def test_enable_success(self, cli_runner, mock_db): + """Test successful enabling of memory statistics.""" + result = cli_runner.invoke(cli, ['config', 'memory-stats', 'enable']) + assert result.exit_code == 0 + mock_db.mod_entry.assert_called_once_with( + MEMORY_STATISTICS_TABLE, + MEMORY_STATISTICS_KEY, + {"enabled": "true"} + ) + assert "successfully" in result.output + assert "config save" in result.output + + +class TestMemoryStatisticsDisable: + def test_disable_success(self, cli_runner, mock_db): + """Test successful disabling of memory statistics.""" + result = cli_runner.invoke(cli, ['config', 'memory-stats', 'disable']) + assert result.exit_code == 0 + mock_db.mod_entry.assert_called_once_with( + MEMORY_STATISTICS_TABLE, + MEMORY_STATISTICS_KEY, + {"enabled": "false"} + ) + assert "successfully" in result.output + assert "config save" in result.output + + +class TestSamplingInterval: + @pytest.mark.parametrize("interval", [ + SAMPLING_INTERVAL_MIN, + SAMPLING_INTERVAL_MAX, + (SAMPLING_INTERVAL_MIN + SAMPLING_INTERVAL_MAX) // 2 + ]) + def test_valid_sampling_intervals(self, interval, cli_runner, mock_db): + """Test setting valid sampling intervals.""" + result = cli_runner.invoke(cli, ['config', 'memory-stats', 'sampling-interval', str(interval)]) + assert result.exit_code == 0 + mock_db.mod_entry.assert_called_once_with( + MEMORY_STATISTICS_TABLE, + MEMORY_STATISTICS_KEY, + {"sampling_interval": str(interval)} + ) + assert f"set to {interval}" in result.output + + @pytest.mark.parametrize("interval", [ + SAMPLING_INTERVAL_MIN - 1, + SAMPLING_INTERVAL_MAX + 1, + 0, + -1, + 256 + ]) + def test_invalid_sampling_intervals(self, interval, cli_runner, mock_db): + """Test handling of invalid sampling intervals.""" + result = cli_runner.invoke(cli, ['config', 'memory-stats', 'sampling-interval', str(interval)]) + assert "Error" in result.output + assert not mock_db.mod_entry.called + + @pytest.mark.parametrize("exception", [ + KeyError("Key not found"), + ConnectionError("Connection failed"), + ValueError("Invalid value"), + RuntimeError("Runtime error") + ]) + def test_sampling_interval_specific_errors(self, exception, cli_runner, mock_db): + """Test handling of specific errors when setting sampling interval.""" + mock_db.mod_entry.side_effect = exception + result = cli_runner.invoke(cli, ['config', 'memory-stats', 'sampling-interval', '5']) + assert result.exit_code == 0 + assert "Error" in result.output + assert str(exception) in result.output + + +class TestRetentionPeriod: + @pytest.mark.parametrize("period", [ + RETENTION_PERIOD_MIN, + RETENTION_PERIOD_MAX, + (RETENTION_PERIOD_MIN + RETENTION_PERIOD_MAX) // 2 + ]) + def test_valid_retention_periods(self, period, cli_runner, mock_db): + """Test setting valid retention periods.""" + result = cli_runner.invoke(cli, ['config', 'memory-stats', 'retention-period', str(period)]) + assert result.exit_code == 0 + mock_db.mod_entry.assert_called_once_with( + MEMORY_STATISTICS_TABLE, + MEMORY_STATISTICS_KEY, + {"retention_period": str(period)} + ) + assert f"set to {period}" in result.output + + @pytest.mark.parametrize("period", [ + RETENTION_PERIOD_MIN - 1, + RETENTION_PERIOD_MAX + 1, + 0, + -1, + 256 + ]) + def test_invalid_retention_periods(self, period, cli_runner, mock_db): + """Test handling of invalid retention periods.""" + result = cli_runner.invoke(cli, ['config', 'memory-stats', 'retention-period', str(period)]) + assert "Error" in result.output + assert not mock_db.mod_entry.called + + @pytest.mark.parametrize("exception", [ + KeyError("Key not found"), + ConnectionError("Connection failed"), + ValueError("Invalid value"), + RuntimeError("Runtime error") + ]) + def test_retention_period_specific_errors(self, exception, cli_runner, mock_db): + """Test handling of specific errors when setting retention period.""" + mock_db.mod_entry.side_effect = exception + result = cli_runner.invoke(cli, ['config', 'memory-stats', 'retention-period', '15']) + assert result.exit_code == 0 + assert "Error" in result.output + assert str(exception) in result.output + + +class TestSyslogLogging: + @pytest.mark.parametrize("log_level,expected_level", [ + ("INFO", syslog.LOG_INFO), + ("ERROR", syslog.LOG_ERR) + ]) + def test_syslog_logging(self, log_level, expected_level): + """Test syslog logging functionality.""" + with patch('syslog.syslog') as mock_syslog: + log_to_syslog("Test message", expected_level) + mock_syslog.assert_called_once_with(expected_level, "Test message") + + def test_syslog_logging_default_level(self): + """Test syslog logging with default log level.""" + with patch('syslog.syslog') as mock_syslog: + log_to_syslog("Test message") + mock_syslog.assert_called_once_with(syslog.LOG_INFO, "Test message") + + def test_syslog_logging_error(self): + """Test syslog logging error handling.""" + with patch('syslog.syslog', side_effect=OSError("Syslog error")), \ + patch('click.echo') as mock_echo: + log_to_syslog("Test message") + mock_echo.assert_called_once_with("System error while logging to syslog: Syslog error", err=True) + + def test_syslog_logging_value_error(self): + """Test syslog logging ValueError handling.""" + invalid_level = -999 + + with patch('syslog.syslog', side_effect=ValueError("Invalid log level")), \ + patch('click.echo') as mock_echo: + log_to_syslog("Test message", invalid_level) + mock_echo.assert_called_once_with( + "Invalid syslog parameters: Invalid log level", + err=True + ) + + def test_syslog_logging_value_error_empty_message(self): + """Test syslog logging ValueError handling with empty message.""" + with patch('syslog.syslog', side_effect=ValueError("Empty message not allowed")), \ + patch('click.echo') as mock_echo: + log_to_syslog("") + mock_echo.assert_called_once_with( + "Invalid syslog parameters: Empty message not allowed", + err=True + ) + + +def test_main_cli_integration(): + """Test the main CLI integration with actual command.""" + runner = CliRunner() + + with patch('config.memory_statistics.MemoryStatisticsDB.get_db') as mock_get_db: + mock_db = Mock() + mock_get_db.return_value = mock_db + + result = runner.invoke(cli, ['config', 'memory-stats', 'sampling-interval', '5']) + assert result.exit_code == 0 + mock_get_db.assert_called_once() + + +def test_script_execution(): + """Test that the script runs successfully.""" + result = subprocess.run(["python3", + "config/memory_statistics.py"], capture_output=True) + assert result.returncode == 0 + + +def test_syslog_closelog(): + """Test that syslog.closelog is called when the script exits.""" + with patch('syslog.closelog') as mock_closelog: + module_code = compile( + ''' +try: + cli() +finally: + syslog.closelog() + ''', + 'memory_statistics.py', + 'exec' + ) + + namespace = { + '__name__': '__main__', + 'cli': Mock(), + 'syslog': Mock(closelog=mock_closelog) + } + + exec(module_code, namespace) + + mock_closelog.assert_called_once() + + +def test_main_execution(): + """Test the script's main execution block including the try-finally structure.""" + script_path = os.path.abspath("config/memory_statistics.py") + + with patch('syslog.closelog') as mock_closelog, \ + patch('click.group', return_value=Mock()) as mock_group: + + namespace = { + '__name__': '__main__', + 'syslog': Mock(closelog=mock_closelog), + 'click': Mock(group=mock_group), + } + + with open(script_path, 'r') as file: + script_content = file.read() + + compiled_code = compile(script_content, script_path, 'exec') + + exec(compiled_code, namespace) + + mock_closelog.assert_called_once() + mock_group.assert_called() diff --git a/tests/show_memory_statistics_test.py b/tests/show_memory_statistics_test.py new file mode 100644 index 0000000000..d47ccbe1b4 --- /dev/null +++ b/tests/show_memory_statistics_test.py @@ -0,0 +1,730 @@ +# Standard library imports +import json +import os +import socket +import signal +import syslog +import unittest +from unittest.mock import MagicMock, Mock, patch + +# Third-party library imports +import click +import pytest +from click.testing import CliRunner + +# Local imports +from show.memory_statistics import ( + Config, + ConnectionError, + DatabaseError, + Dict2Obj, + SonicDBConnector, + SocketManager, + ResourceManager, + send_data, + format_field_value, + display_config, + show_statistics, + show_configuration, + shutdown_handler, + main, +) + + +class TestConfig(unittest.TestCase): + """Tests for Config class""" + + def test_default_config_values(self): + """Test that Config class has correct default values""" + self.assertEqual(Config.SOCKET_PATH, '/var/run/dbus/memstats.socket') + self.assertEqual(Config.SOCKET_TIMEOUT, 30) + self.assertEqual(Config.BUFFER_SIZE, 8192) + self.assertEqual(Config.MAX_RETRIES, 3) + self.assertEqual(Config.RETRY_DELAY, 1.0) + + def test_default_config_dictionary(self): + """Test the DEFAULT_CONFIG dictionary has correct values""" + expected = { + "enabled": "false", + "sampling_interval": "5", + "retention_period": "15" + } + self.assertEqual(Config.DEFAULT_CONFIG, expected) + + +class TestDict2Obj(unittest.TestCase): + """Tests for Dict2Obj class""" + + def test_dict_conversion(self): + """Test basic dictionary conversion""" + test_dict = {"name": "test", "value": 123} + obj = Dict2Obj(test_dict) + self.assertEqual(obj.name, "test") + self.assertEqual(obj.value, 123) + + def test_nested_dict_conversion(self): + """Test nested dictionary conversion""" + test_dict = { + "outer": { + "inner": "value", + "number": 42 + } + } + obj = Dict2Obj(test_dict) + self.assertEqual(obj.outer.inner, "value") + self.assertEqual(obj.outer.number, 42) + + def test_list_conversion(self): + """Test list conversion""" + test_list = [{"name": "item1"}, {"name": "item2"}] + obj = Dict2Obj(test_list) + self.assertEqual(obj.items[0].name, "item1") + self.assertEqual(obj.items[1].name, "item2") + + def test_invalid_input(self): + """Test invalid input handling""" + with self.assertRaises(ValueError): + Dict2Obj("invalid") + + def test_to_dict_conversion(self): + """Test conversion back to dictionary""" + original = {"name": "test", "nested": {"value": 123}} + obj = Dict2Obj(original) + result = obj.to_dict() + self.assertEqual(result, original) + + def test_nested_list_conversion(self): + """Test conversion of nested lists with dictionaries""" + test_dict = { + "items": [ + {"id": 1, "subitems": [{"name": "sub1"}, {"name": "sub2"}]}, + {"id": 2, "subitems": [{"name": "sub3"}, {"name": "sub4"}]} + ] + } + obj = Dict2Obj(test_dict) + self.assertEqual(obj.items[0].subitems[0].name, "sub1") + self.assertEqual(obj.items[1].subitems[1].name, "sub4") + + def test_empty_structures(self): + """Test conversion of empty structures""" + self.assertEqual(Dict2Obj({}).to_dict(), {}) + self.assertEqual(Dict2Obj([]).to_dict(), []) + + def test_dict2obj_invalid_input(self): + with pytest.raises(ValueError): + Dict2Obj("invalid_input") + + def test_complex_nested_structure(self): + """Test conversion of complex nested structures""" + test_dict = { + "level1": { + "level2": { + "level3": { + "value": 42, + "list": [1, 2, {"nested": "value"}] + } + } + } + } + obj = Dict2Obj(test_dict) + self.assertEqual(obj.level1.level2.level3.value, 42) + self.assertEqual(obj.level1.level2.level3.list[2].nested, "value") + + +class TestCLICommands(unittest.TestCase): + """Tests for CLI commands""" + + def setUp(self): + self.runner = CliRunner() + + @patch('show.memory_statistics.send_data') + def test_show_statistics(self, mock_send_data): + """Test show statistics command""" + mock_response = Dict2Obj({ + "status": True, + "data": "Memory Statistics Data" + }) + mock_send_data.return_value = mock_response + + result = self.runner.invoke(show_statistics, ['--from', '1h', '--to', 'now']) + self.assertEqual(result.exit_code, 0) + self.assertIn("Memory Statistics", result.output) + + @patch('show.memory_statistics.send_data') + def test_show_statistics_with_metric(self, mock_send_data): + """Test show statistics with specific metric""" + mock_response = Dict2Obj({ + "status": True, + "data": "Memory Usage: 75%" + }) + mock_send_data.return_value = mock_response + + result = self.runner.invoke(show_statistics, + ['--select', 'used_memory']) + self.assertEqual(result.exit_code, 0) + self.assertIn("Memory Usage", result.output) + + @patch('show.memory_statistics.send_data') + def test_show_statistics_error_handling(self, mock_send_data): + """Test error handling in show statistics""" + mock_send_data.side_effect = ConnectionError("Failed to connect") + + result = self.runner.invoke(show_statistics) + self.assertEqual(result.exit_code, 1) + self.assertIn("Error", result.output) + + @patch('show.memory_statistics.send_data') + def test_show_statistics_empty_data(self, mock_send): + """Test show_statistics with empty data""" + mock_send.return_value = Dict2Obj({"data": ""}) + result = self.runner.invoke(show_statistics) + self.assertIn("No memory statistics data available", result.output) + + +class TestShowConfiguration(unittest.TestCase): + """Tests for show_configuration command""" + + def setUp(self): + self.runner = CliRunner() + + @patch('show.memory_statistics.SonicDBConnector') + def test_show_config_error(self, mock_db): + """Test show_configuration error handling""" + mock_db.side_effect = Exception("DB Connection Error") + result = self.runner.invoke(show_configuration) + self.assertEqual(result.exit_code, 1) + self.assertIn("Error", result.output) + + +class TestHelperFunctions(unittest.TestCase): + """Tests for helper functions""" + + def test_format_field_value(self): + """Test field value formatting""" + self.assertEqual(format_field_value("enabled", "true"), "True") + self.assertEqual(format_field_value("enabled", "false"), "False") + self.assertEqual(format_field_value("retention_period", "15"), "15") + self.assertEqual(format_field_value("sampling_interval", "Unknown"), "Not configured") + + def test_resource_manager_cleanup_no_resources(self): + """Test ResourceManager cleanup when no resources exist""" + resource_manager = ResourceManager() + resource_manager.cleanup() + + def test_shutdown_handler_cleanup(self): + """Test shutdown_handler performs cleanup""" + resource_manager = ResourceManager() + resource_manager.db_connector = MagicMock() + resource_manager.socket_manager = MagicMock() + + with pytest.raises(SystemExit) as exc_info: + shutdown_handler(signal.SIGTERM, None, resource_manager) + + resource_manager.socket_manager.close.assert_called_once() + assert exc_info.value.code == 0 + + +class TestSendData(unittest.TestCase): + """Tests for send_data function""" + + @patch('show.memory_statistics.SocketManager') + def test_send_data_non_dict_response(self, mock_socket_manager): + """Test send_data with non-dict response""" + mock_instance = Mock() + mock_socket_manager.return_value = mock_instance + mock_instance.receive_all.return_value = json.dumps(["not a dict"]) + + with self.assertRaises(ValueError): + send_data("test_command", {}) + + @patch('show.memory_statistics.SocketManager') + def test_successful_response_with_status(self, mock_socket_manager): + """Test successful response with status field""" + mock_instance = Mock() + mock_socket_manager.return_value = mock_instance + response_data = { + "status": True, + "data": "test data" + } + mock_instance.receive_all.return_value = json.dumps(response_data) + + result = send_data("test_command", {}) + self.assertTrue(result.status) + self.assertEqual(result.data, "test data") + + @patch('show.memory_statistics.SocketManager') + def test_response_without_status_field(self, mock_socket_manager): + """Test response without status field (should default to True)""" + mock_instance = Mock() + mock_socket_manager.return_value = mock_instance + response_data = { + "data": "test data" + } + mock_instance.receive_all.return_value = json.dumps(response_data) + + result = send_data("test_command", {}) + self.assertTrue(getattr(result, 'status', True)) + self.assertEqual(result.data, "test data") + + @patch('show.memory_statistics.SocketManager') + def test_complex_response_object_conversion(self, mock_socket_manager): + """Test conversion of complex response object""" + mock_instance = Mock() + mock_socket_manager.return_value = mock_instance + response_data = { + "status": True, + "data": { + "metrics": [ + {"name": "memory", "value": 100}, + {"name": "cpu", "value": 50} + ], + "timestamp": "2024-01-01" + } + } + mock_instance.receive_all.return_value = json.dumps(response_data) + + result = send_data("test_command", {}) + self.assertTrue(result.status) + self.assertEqual(result.data.metrics[0].name, "memory") + self.assertEqual(result.data.metrics[1].value, 50) + self.assertEqual(result.data.timestamp, "2024-01-01") + + @patch('show.memory_statistics.SocketManager') + def test_send_data_json_decode_error(self, mock_socket_manager): + """Test send_data handles JSON decode errors""" + mock_instance = Mock() + mock_socket_manager.return_value = mock_instance + mock_instance.receive_all.return_value = "invalid json" + + with self.assertRaises(ValueError): + send_data("test_command", {}) + + @patch('show.memory_statistics.SocketManager') + def test_send_data_invalid_response_format(self, mock_socket_manager): + """Test send_data handles invalid response format""" + mock_instance = Mock() + mock_socket_manager.return_value = mock_instance + mock_instance.receive_all.return_value = json.dumps(["not a dict"]) + + with self.assertRaises(ValueError): + send_data("test_command", {}) + + @patch("show.memory_statistics.SocketManager") + def test_send_data_invalid_response(self, mock_socket_manager): + """Test send_data with invalid JSON response""" + mock_instance = Mock() + mock_socket_manager.return_value = mock_instance + mock_instance.receive_all.return_value = "invalid_json" + + with self.assertRaises(ValueError): + send_data("test_command", {}) + + +class TestDisplayConfig(unittest.TestCase): + """Tests for display_config function""" + + def test_display_config_success(self): + """Test successful config display""" + mock_connector = MagicMock() + mock_connector.get_memory_statistics_config.return_value = { + "enabled": "true", + "retention_period": "15", + "sampling_interval": "5" + } + + runner = CliRunner() + with runner.isolation(): + display_config(mock_connector) + + def test_display_config_error(self): + """Test error handling in display config""" + mock_connector = MagicMock() + mock_connector.get_memory_statistics_config.side_effect = RuntimeError("Config error") + + with pytest.raises(click.ClickException): + display_config(mock_connector) + + @patch('show.memory_statistics.ConfigDBConnector') + def test_get_memory_statistics_config_invalid_data(self, mock_connector): + """Test get_memory_statistics_config with invalid data""" + mock_instance = mock_connector.return_value + mock_instance.connect = MagicMock() + mock_instance.get_table = MagicMock(return_value={"invalid_key": "invalid_value"}) + + db_connector = SonicDBConnector() + config = db_connector.get_memory_statistics_config() + assert config == Config.DEFAULT_CONFIG + + @patch('show.memory_statistics.click.echo') + @patch('show.memory_statistics.SonicDBConnector') + def test_show_configuration_database_error(self, mock_sonic_db, mock_echo): + """Test show_configuration handles database errors""" + mock_instance = mock_sonic_db.return_value + mock_instance.get_memory_statistics_config.side_effect = Exception("DB error") + + runner = CliRunner() + with patch('show.memory_statistics.sys.exit') as mock_exit: + runner.invoke(show_configuration, catch_exceptions=False) + + mock_echo.assert_any_call("Error: Failed to retrieve configuration: DB error", err=True) + + assert mock_exit.call_count >= 1 + mock_exit.assert_any_call(1) + + +class TestFormatFieldValue: + """Tests for format_field_value function using pytest""" + + @pytest.mark.parametrize("field_name,value,expected", [ + ("enabled", "true", "True"), + ("enabled", "false", "False"), + ("enabled", "TRUE", "True"), + ("enabled", "FALSE", "False"), + ("retention_period", "15", "15"), + ("sampling_interval", "5", "5"), + ("any_field", "Unknown", "Not configured"), + ]) + def test_format_field_value(self, field_name, value, expected): + assert format_field_value(field_name, value) == expected + + +class TestMemoryStatistics(unittest.TestCase): + def setUp(self): + self.cli_runner = CliRunner() + + def test_dict2obj_invalid_input(self): + """Test Dict2Obj with invalid input (line 71)""" + with self.assertRaises(ValueError): + Dict2Obj("invalid input") + + def test_dict2obj_empty_list(self): + """Test Dict2Obj with empty list (line 78)""" + obj = Dict2Obj([]) + self.assertEqual(obj.to_dict(), []) + + @patch('show.memory_statistics.SonicDBConnector') + def test_show_configuration_error(self, mock_db): + """Test show configuration error (line 302)""" + mock_db.side_effect = Exception("DB connection failed") + result = self.cli_runner.invoke(show_configuration) + self.assertIn("Error: DB connection failed", result.output) + + @patch('show.memory_statistics.signal.signal') + def test_main_error(self, mock_signal): + """Test main function error handling (lines 344, 355)""" + mock_signal.side_effect = Exception("Signal registration failed") + + with self.assertRaises(SystemExit): + main() + + def test_socket_manager_validation(self): + """Test socket path validation (line 409)""" + with self.assertRaises(ConnectionError): + SocketManager("/nonexistent/path/socket") + + +class TestAdditionalMemoryStatisticsCLI(unittest.TestCase): + + def setUp(self): + self.runner = CliRunner() + + def test_dict2obj_with_nested_data(self): + """Test Dict2Obj with deeply nested dictionaries""" + data = {'a': {'b': {'c': 1}}, 'list': [1, {'d': 2}]} + obj = Dict2Obj(data) + self.assertEqual(obj.a.b.c, 1) + self.assertEqual(obj.list[1].d, 2) + self.assertEqual(obj.to_dict(), data) + + def test_dict2obj_repr(self): + """Test the __repr__ method of Dict2Obj""" + data = {'a': 1, 'b': {'c': 2}} + obj = Dict2Obj(data) + repr_str = repr(obj) + self.assertTrue(repr_str.startswith('