diff --git a/scripts/sonic-bootchart b/scripts/sonic-bootchart new file mode 100755 index 0000000000..86e993d395 --- /dev/null +++ b/scripts/sonic-bootchart @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 + +import click +import sys +import configparser +import functools +import os +import glob +from tabulate import tabulate +import utilities_common.cli as clicommon + +SYSTEMD_BOOTCHART = "/lib/systemd/systemd-bootchart" +BOOTCHART_CONF = "/etc/systemd/bootchart.conf" +BOOTCHART_DEFAULT_OUTPUT_DIR = "/run/log/" +BOOTCHART_DEFAULT_OUTPUT_GLOB = os.path.join(BOOTCHART_DEFAULT_OUTPUT_DIR, "bootchart-*.svg") + +class BootChartConfigParser(configparser.ConfigParser): + """ Custom bootchart config parser. Changes the way ConfigParser passes options """ + + def optionxform(self, option): + """ Pass options as is, without modifications """ + return option + + +def exit_cli(*args, **kwargs): + """ Print a message and exit with rc 1. """ + click.secho(*args, **kwargs) + sys.exit(1) + + +def root_privileges_required(func): + """ Decorates a function, so that the function is invoked + only if the user is root. """ + @functools.wraps(func) + def wrapped_function(*args, **kwargs): + """ Wrapper around func. """ + if os.geteuid() != 0: + exit_cli("Root privileges required for this operation", fg="red") + return func(*args, **kwargs) + + wrapped_function.__doc__ += "\n\n NOTE: This command requires elevated (root) privileges to run." + return wrapped_function + + +def check_bootchart_installed(): + """ Fails imidiatelly if bootchart is not installed """ + if not os.path.exists(SYSTEMD_BOOTCHART): + exit_cli("systemd-bootchart is not installed", fg="red") + + +def get_enabled_status(): + """ Get systemd-bootchart status """ + return clicommon.run_command("systemctl is-enabled systemd-bootchart", return_cmd=True) + +def get_active_status(): + """ Get systemd-bootchart status """ + return clicommon.run_command("systemctl is-active systemd-bootchart", return_cmd=True) + +def get_output_files(): + bootchart_output_files = [] + for bootchart_output_file in glob.glob(BOOTCHART_DEFAULT_OUTPUT_GLOB): + bootchart_output_files.append(bootchart_output_file) + return "\n".join(bootchart_output_files) + + +@click.group() +def cli(): + """ Main CLI group """ + check_bootchart_installed() + + +@cli.command() +@root_privileges_required +def enable(): + """ Enable bootchart """ + clicommon.run_command("systemctl enable systemd-bootchart", display_cmd=True) + + +@cli.command() +@root_privileges_required +def disable(): + """ Disable bootchart """ + clicommon.run_command("systemctl disable systemd-bootchart", display_cmd=True) + + +@cli.command() +@click.option('--time', type=click.IntRange(min=1), required=True) +@click.option('--frequency', type=click.IntRange(min=1), required=True) +@root_privileges_required +def config(time, frequency): + """ Configure bootchart """ + samples = time * frequency + + config = { + 'Samples': str(samples), + 'Frequency': str(frequency), + } + bootchart_config = BootChartConfigParser() + bootchart_config.read(BOOTCHART_CONF) + bootchart_config['Bootchart'].update(config) + with open(BOOTCHART_CONF, 'w') as config_file: + bootchart_config.write(config_file, space_around_delimiters=False) + + +@cli.command() +def show(): + """ Display bootchart configuration """ + bootchart_config = BootChartConfigParser() + bootchart_config.read(BOOTCHART_CONF) + + try: + samples = int(bootchart_config["Bootchart"]["Samples"]) + frequency = int(bootchart_config["Bootchart"]["Frequency"]) + except KeyError as key: + raise click.ClickException(f"Failed to parse bootchart config: {key} not found") + except ValueError as err: + raise click.ClickException(f"Failed to parse bootchart config: {err}") + + try: + time = samples // frequency + except ZeroDivisionError: + raise click.ClickException(f"Invalid frequency value: {frequency}") + + field_values = { + "Status": get_enabled_status(), + "Operational Status": get_active_status(), + "Frequency": frequency, + "Time (sec)": time, + "Output": get_output_files(), + } + + click.echo(tabulate([field_values.values()], field_values.keys())) + + +def main(): + cli() + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py index 3f5e1b6633..7f617905da 100644 --- a/setup.py +++ b/setup.py @@ -143,6 +143,7 @@ 'scripts/watermarkstat', 'scripts/watermarkcfg', 'scripts/sonic-kdump-config', + 'scripts/sonic-bootchart', 'scripts/centralize_database', 'scripts/null_route_helper', 'scripts/coredump_gen_handler.py', diff --git a/tests/sonic_bootchart_test.py b/tests/sonic_bootchart_test.py new file mode 100755 index 0000000000..f9ecdab1dc --- /dev/null +++ b/tests/sonic_bootchart_test.py @@ -0,0 +1,124 @@ +import os +import subprocess +import pytest +from click.testing import CliRunner +from unittest.mock import patch, Mock +import utilities_common +import imp + +sonic_bootchart = imp.load_source('sonic-bootchart', 'scripts/sonic-bootchart') + +BOOTCHART_OUTPUT_FILES = [ + os.path.join(sonic_bootchart.BOOTCHART_DEFAULT_OUTPUT_DIR, "bootchart-20220504-1040.svg"), + os.path.join(sonic_bootchart.BOOTCHART_DEFAULT_OUTPUT_DIR, "bootchart-20220504-1045.svg"), +] + +@pytest.fixture(autouse=True) +def setup(fs): + # create required file for bootchart installation check + fs.create_file(sonic_bootchart.SYSTEMD_BOOTCHART) + fs.create_file(sonic_bootchart.BOOTCHART_CONF) + for bootchart_output_file in BOOTCHART_OUTPUT_FILES: + fs.create_file(bootchart_output_file) + + with open(sonic_bootchart.BOOTCHART_CONF, 'w') as config_file: + config_file.write(""" + [Bootchart] + Samples=500 + Frequency=25 + """) + + # pass the root user check + with patch("os.geteuid") as mock: + mock.return_value = 0 + yield + + +@patch("utilities_common.cli.run_command") +class TestSonicBootchart: + def test_enable(self, mock_run_command): + runner = CliRunner() + result = runner.invoke(sonic_bootchart.cli.commands['enable'], []) + assert not result.exit_code + mock_run_command.assert_called_with("systemctl enable systemd-bootchart", display_cmd=True) + + def test_disable(self, mock_run_command): + runner = CliRunner() + result = runner.invoke(sonic_bootchart.cli.commands['disable'], []) + assert not result.exit_code + mock_run_command.assert_called_with("systemctl disable systemd-bootchart", display_cmd=True) + + def test_config_show(self, mock_run_command): + def run_command_side_effect(command, **kwargs): + if "is-enabled" in command: + return "enabled" + elif "is-active" in command: + return "active" + else: + raise Exception("unknown command") + + mock_run_command.side_effect = run_command_side_effect + + runner = CliRunner() + result = runner.invoke(sonic_bootchart.cli.commands['show'], []) + assert not result.exit_code + assert result.output == \ + "Status Operational Status Frequency Time (sec) Output\n" \ + "-------- -------------------- ----------- ------------ ------------------------------------\n" \ + "enabled active 25 20 /run/log/bootchart-20220504-1040.svg\n" \ + " /run/log/bootchart-20220504-1045.svg\n" + + result = runner.invoke(sonic_bootchart.cli.commands["config"], ["--time", "2", "--frequency", "50"]) + assert not result.exit_code + + result = runner.invoke(sonic_bootchart.cli.commands['show'], []) + assert not result.exit_code + assert result.output == \ + "Status Operational Status Frequency Time (sec) Output\n" \ + "-------- -------------------- ----------- ------------ ------------------------------------\n" \ + "enabled active 50 2 /run/log/bootchart-20220504-1040.svg\n" \ + " /run/log/bootchart-20220504-1045.svg\n" + + # Input validation tests + + result = runner.invoke(sonic_bootchart.cli.commands["config"], ["--time", "0", "--frequency", "50"]) + assert result.exit_code + + result = runner.invoke(sonic_bootchart.cli.commands["config"], ["--time", "2", "--frequency", "-5"]) + assert result.exit_code + + def test_invalid_config_show(self, mock_run_command): + with open(sonic_bootchart.BOOTCHART_CONF, 'w') as config_file: + config_file.write(""" + [Bootchart] + Samples=100 + """) + + runner = CliRunner() + result = runner.invoke(sonic_bootchart.cli.commands['show'], []) + assert result.exit_code + assert result.output == "Error: Failed to parse bootchart config: 'Frequency' not found\n" + + with open(sonic_bootchart.BOOTCHART_CONF, 'w') as config_file: + config_file.write(""" + [Bootchart] + Samples=abc + Frequency=def + """) + + runner = CliRunner() + result = runner.invoke(sonic_bootchart.cli.commands['show'], []) + assert result.exit_code + assert result.output == "Error: Failed to parse bootchart config: invalid literal for int() with base 10: 'abc'\n" + + with open(sonic_bootchart.BOOTCHART_CONF, 'w') as config_file: + config_file.write(""" + [Bootchart] + Samples=100 + Frequency=0 + """) + + runner = CliRunner() + result = runner.invoke(sonic_bootchart.cli.commands['show'], []) + assert result.exit_code + assert result.output == "Error: Invalid frequency value: 0\n"