Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding unit tests for utilities.py #415

Merged
merged 26 commits into from
Apr 4, 2023
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
132952f
Adding OpenAI generated functional level tests
PhillypHenning Mar 15, 2023
a11a81d
Fixing naming and folder path
PhillypHenning Mar 16, 2023
f10468f
Adding handle_hook tests
PhillypHenning Mar 16, 2023
eaeba19
test_utilities.py - 11 tests added
PhillypHenning Mar 17, 2023
e053e3f
renaming to avoid naming conflict
PhillypHenning Mar 17, 2023
3b1985b
formatting
PhillypHenning Mar 17, 2023
739c879
removing test_hello
PhillypHenning Mar 17, 2023
7c427d3
Updating import statements
PhillypHenning Mar 20, 2023
5b695a9
Updating `test_assets` -> `assets`
PhillypHenning Mar 20, 2023
b0afda6
Fixing pylinting
PhillypHenning Mar 20, 2023
08f5cde
Updating unit test start command
PhillypHenning Mar 20, 2023
dddf555
Adding newline - refresh unit test (updated tox.ini)
PhillypHenning Mar 20, 2023
99dccac
Updating tox.ini file (adding unit env)
PhillypHenning Mar 20, 2023
8eb4020
Disable logging
arm4b Mar 22, 2023
1253cde
Disable logging to file, when the filename is not specified
arm4b Mar 22, 2023
7fc6697
Add a condition to skip the filename logging
arm4b Mar 29, 2023
bbf3d92
Merge branch 'main' into bitops_utest
arm4b Mar 29, 2023
dd60f3a
Fix disable logging to filename for all the testing environments
arm4b Mar 29, 2023
f09c813
Merge branch 'bitops_utest' of https://github.com/bitovi/bitops into …
arm4b Mar 29, 2023
791c207
Simplify test load_yaml() function
arm4b Mar 29, 2023
aadff83
Update run_cmd for the tests
arm4b Mar 30, 2023
95a5d6c
Silence pylint unused arguments
arm4b Mar 30, 2023
9b65f8c
Remove the turn_off_logger call in tests as it's unnecesarry now
arm4b Mar 30, 2023
8cc1637
Update handle_hooks to return bool
arm4b Mar 30, 2023
446569c
Better use patching in the Unit tests
arm4b Mar 30, 2023
92ffbd1
Update scripts/plugins/config/parser.py to exit with 101
arm4b Apr 4, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions scripts/plugins/config/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,15 @@ def get_config_list(config_file, schema_file):
\n\t PLUGIN SCHEMA FILE PATH: [{schema_file}] \
\n\n"
)

schema_yaml = load_yaml(schema_file)
config_yaml = load_yaml(config_file)
try:
schema_yaml = load_yaml(schema_file)
config_yaml = load_yaml(config_file)
except FileNotFoundError as e:
logger.error(
f"Required config file was not found. \
To fix this please add the following file: [{e.filename}]"
)
sys.exit(2)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

run_cmd try catch blocks exit with 1xx, should we consider making this a 1xx exit as well? What denotes a 2 vs 101

Copy link
Member

@arm4b arm4b Mar 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's how you added it here initially: eaeba19#diff-ec9057b7cfb0579064835fe003bf275eb479b2910d976987366051d5d7593c9dR24, so that's more a question to you.

There is no difference to me, unless we're returning the non-zero error code.

Copy link
Member

@arm4b arm4b Apr 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in 92ffbd1

Suggested change
sys.exit(2)
sys.exit(101)

schema = convert_yaml_to_dict(schema_yaml)
schema_properties_list = generate_schema_keys(schema)
schema_list = generate_populated_schema_list(schema, schema_properties_list, config_yaml)
Expand Down
32 changes: 19 additions & 13 deletions scripts/plugins/deploy_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,19 +271,25 @@ def deploy_plugins(): # pylint: disable=too-many-locals,too-many-branches,too-m
\n\t\t\tSTACK ACTION: [{stack_action}]"
)

result = run_cmd(
[
plugin_deploy_language,
plugin_deploy_script_path,
stack_action,
]
)
if result.returncode != 0:
logger.warning(f"\n~#~#~#~DEPLOYING OPS REPO [{deployment}] FAILED~#~#~#~")
if BITOPS_FAST_FAIL_MODE:
sys.exit(result.returncode)
else:
logger.info(f"\n~#~#~#~DEPLOYING OPS REPO [{deployment}] SUCCESSFULLY COMPLETED~#~#~#~")
try:
result = run_cmd(
[
plugin_deploy_language,
plugin_deploy_script_path,
stack_action,
]
)
if result.returncode != 0:
logger.warning(f"\n~#~#~#~DEPLOYING OPS REPO [{deployment}] FAILED~#~#~#~")
if BITOPS_FAST_FAIL_MODE:
sys.exit(result.returncode)
else:
logger.info(
f"\n~#~#~#~DEPLOYING OPS REPO [{deployment}] SUCCESSFULLY COMPLETED~#~#~#~"
)
except Exception as e:
logger.error(f"Error running deployment script: {e}")
sys.exit(101)

# ~#~#~#~#~#~# STAGE 5 - AFTER HOOKS #~#~#~#~#~#~#
if plugin_deploy_after_hook_scripts_flag:
Expand Down
8 changes: 7 additions & 1 deletion scripts/plugins/install_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,13 @@ def install_plugins(): # pylint: disable=too-many-locals,too-many-statements,to
logger.error(f"File does not exist: [{plugin_install_script_path}]")
sys.exit(1)

result = run_cmd([plugin_install_language, plugin_install_script_path])
try:
result = run_cmd([plugin_install_language, plugin_install_script_path])
except Exception as e:
logger.error(
f"Failed to run plugin install script: [{plugin_install_script_path}]. Error: [{e}]"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error message for deploy and install should be similar.

Copy link
Member

@arm4b arm4b Mar 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one comes from the Exception, eg. when something wrong happens on the Popen system side like a timeout, permissions issue, etc.

What would be the more expected error message to you?
Feel free to push directly to the branch or you can also propose a code suggestion (https://egghead.io/lessons/github-add-suggestions-in-a-github-pr-review) for better clarity.

)
sys.exit(101)
if result.returncode == 0:
logger.info(f"~#~#~#~INSTALLING PLUGIN [{plugin_config}] SUCCESSFULLY COMPLETED~#~#~#~")
logger.debug(
Expand Down
11 changes: 9 additions & 2 deletions scripts/plugins/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ def formatter_message(message, use_color=BITOPS_LOGGING_COLOR):
return message


def turn_off_logger():
"""
Disables the logger from printing.
"""
logging.getLogger("bitops-logger").disabled = True


class BitOpsFormatter(logging.Formatter):
"""
Class that controls the formatting of logging text, adds colors if enabled.
Expand Down Expand Up @@ -93,7 +100,7 @@ def format(self, record):
)


logger = logging.getLogger()
logger = logging.getLogger("bitops-logger")
logger.setLevel(BITOPS_LOGGING_LEVEL)

handler = logging.StreamHandler(sys.stdout)
Expand All @@ -104,7 +111,7 @@ def format(self, record):
logger.addHandler(handler)


if BITOPS_LOGGING_FILENAME is not None:
if BITOPS_LOGGING_FILENAME not in [None, "", "false"]:
# This assumes that the user wants to save output to a filename

# Create the directory if it doesn't exist
Expand Down
2 changes: 2 additions & 0 deletions scripts/plugins/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def parse_config(dictionary, dotted_key_list):
BITOPS_ENV_fast_fail_mode = os.environ.get("BITOPS_FAST_FAIL")
BITOPS_ENV_run_mode = os.environ.get("BITOPS_MODE") # TODO: CLEAN
BITOPS_ENV_logging_level = os.environ.get("BITOPS_LOGGING_LEVEL")
BITOPS_ENV_logging_filename = os.environ.get("BITOPS_LOGGING_FILENAME")
BITOPS_ENV_plugin_dir = os.environ.get("BITOPS_PLUGIN_DIR")

BITOPS_ENV_default_folder = os.environ.get("BITOPS_DEFAULT_FOLDER")
Expand Down Expand Up @@ -135,6 +136,7 @@ def parse_config(dictionary, dotted_key_list):
)

BITOPS_LOGGING_FILENAME = get_first(
BITOPS_ENV_logging_filename,
parse_config(bitops_user_configuration, "bitops.logging.filename"),
parse_config(bitops_build_configuration, "bitops.logging.filename"),
None,
Expand Down
62 changes: 28 additions & 34 deletions scripts/plugins/utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

from .settings import BITOPS_FAST_FAIL_MODE
from .logging import logger, mask_message
from .doc import get_doc


def add_value_to_env(export_env, value):
Expand Down Expand Up @@ -46,56 +45,46 @@ def add_value_to_env(export_env, value):
)


def load_yaml(inc_yaml):
def load_yaml(filename: str) -> Union[dict, None]:
"""
This function attempts to load a YAML file from a given location,
and exits if the file is not found. It returns the loaded YAML file if successful.
"""
out_yaml = None
try:
with open(inc_yaml, "r", encoding="utf8") as stream:
out_yaml = yaml.load(stream, Loader=yaml.FullLoader)
except FileNotFoundError as e:
msg, exit_code = get_doc("missing_required_file")
logger.error(f"{msg} [{e.filename}]")
logger.debug(e)
sys.exit(exit_code)
with open(filename, "r", encoding="utf8") as stream:
out_yaml = yaml.load(stream, Loader=yaml.FullLoader)

return out_yaml


def run_cmd(command: Union[list, str]) -> subprocess.Popen:
"""Run a linux command and return Popen instance as a result"""
try:
with subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
) as process:
for combined_output in process.stdout:
# TODO: parse output for secrets
# TODO: specify plugin and output tight output (no extra newlines)
# TODO: can we modify a specific handler to add handler.terminator = "" ?
sys.stdout.write(mask_message(combined_output))

# This polls the async function to get information
# about the status of the process execution.
# Namely the return code which is used elsewhere.
process.communicate()

except Exception as exc:
logger.error(exc)
sys.exit(101)
with subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
) as process:
for combined_output in process.stdout:
# TODO: specify plugin and output tight output (no extra newlines)
sys.stdout.write(mask_message(combined_output))
# This polls the async function to get information
# about the status of the process execution.
# Namely the return code which is used elsewhere.
process.communicate()

return process


def handle_hooks(mode, hooks_folder, source_folder):
def handle_hooks(mode, hooks_folder, source_folder) -> bool:
"""
Processes a bitops before/after hook by invoking bash script(s) within the hooks folder(s).
"""
# Checks if the folder exists, if not, move on
if not os.path.isdir(hooks_folder):
return
return False
if mode not in ["before", "after"]:
return False

original_directory = os.getcwd()
os.chdir(source_folder)
Expand All @@ -115,7 +104,11 @@ def handle_hooks(mode, hooks_folder, source_folder):
plugin_before_hook_script_path = hooks_folder + "/" + hook_script
os.chmod(plugin_before_hook_script_path, 775)

result = run_cmd(["bash", plugin_before_hook_script_path])
try:
result = run_cmd(["bash", plugin_before_hook_script_path])
except Exception as e:
logger.error(f"Failed to execute before_hook script command. Error: {e}")
sys.exit(101)
if result.returncode == 0:
logger.info(f"~#~#~#~{umode} HOOK [{hook_script}] SUCCESSFULLY COMPLETED~#~#~#~")
logger.debug(result.stdout)
Expand All @@ -126,3 +119,4 @@ def handle_hooks(mode, hooks_folder, source_folder):
sys.exit(result.returncode)

os.chdir(original_directory)
return True
4 changes: 4 additions & 0 deletions scripts/tests/unit/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import os

# Disable logging to file when running tests
os.environ["BITOPS_LOGGING_FILENAME"] = "false"
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/bin/bash

echo "In before_test.sh"

ls ../../

13 changes: 0 additions & 13 deletions scripts/tests/unit/test_hello.py

This file was deleted.

123 changes: 123 additions & 0 deletions scripts/tests/unit/test_utilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import os
import subprocess
from io import StringIO
from unittest import mock, TestCase
from plugins.utilities import add_value_to_env, load_yaml, run_cmd, handle_hooks


class TestAddValueToEnv(TestCase):
"""Testing add_value_to_env utilties function"""

def setUp(self):
self.export_env = ""
self.value = ""
os.environ.clear()

def test_add_value_to_env_with_value(self):
"""Test the add_value_to_env() function with valid value"""
self.export_env = "ANSIBLE_VERBOSITY"
self.value = "1"

add_value_to_env(self.export_env, self.value)
self.assertEqual(os.environ[self.export_env], self.value)
self.assertEqual(os.environ["BITOPS_" + self.export_env], self.value)

def test_add_value_to_env_with_none(self):
"""Test the add_value_to_env() function with None"""
self.export_env = "ANSIBLE_VERBOSITY"
self.value = None

add_value_to_env(self.export_env, self.value)
self.assertNotIn(self.export_env, os.environ)
self.assertNotIn("BITOPS_" + self.export_env, os.environ)

def test_add_value_to_env_with_list(self):
"""Test the add_value_to_env() function with a list"""
self.export_env = "ANSIBLE_VERBOSITY"
self.value = ["1", "2", "3"]

add_value_to_env(self.export_env, self.value)
self.assertEqual(os.environ[self.export_env], " ".join(self.value))
self.assertEqual(os.environ["BITOPS_" + self.export_env], " ".join(self.value))


class TestLoadYAML(TestCase):
"""Testing load_yaml utilties function"""

def setUp(self):
root_dir = os.getcwd()
self.inc_yaml = f"{root_dir}/prebuilt-config/omnibus/bitops.config.yaml"

def test_load_yaml(self):
"""
Test the load_yaml function.
"""
out_yaml = load_yaml(self.inc_yaml)
self.assertIsNotNone(out_yaml)
self.assertIsInstance(out_yaml, dict)

def test_load_yaml_with_invalid_filename(self):
"""
Test the load_yaml function with a non-existent file.
"""
with self.assertRaises(FileNotFoundError):
load_yaml("invalid_file.yaml")


class TestRunCmd(TestCase):
"""Testing run_cmd utilties function"""

@mock.patch("sys.stdout", new_callable=StringIO)
def test_valid_run_cmd(self, stdout):
"""
Test the run_cmd function with a valid command
"""
process = run_cmd("ls")
self.assertIsInstance(process, subprocess.Popen)
self.assertEqual(process.returncode, 0)
self.assertEqual(process.args, "ls")
self.assertIn("README.md", stdout.getvalue())
self.assertIn("LICENSE.md", stdout.getvalue())

def test_invalid_run_cmd(self):
"""
Test the run_cmd function with an invalid command should throw an exception
"""
with self.assertRaises(Exception) as context:
run_cmd("not_a_real_command")
self.assertIsInstance(context.exception, FileNotFoundError)


class TestHandleHooks(TestCase):
"""Testing handle_hooks utilties function"""

def setUp(self):
self.original_cwd = os.getcwd()
self.hooks_folder = f"{self.original_cwd}/scripts/tests/unit/assets/bitops.before-deploy.d"
self.source_folder = f"{self.original_cwd}/scripts/tests/unit/assets"

def tearDown(self):
os.chdir(self.original_cwd)

def test_handle_hooks_called_with_invalid_folder(self):
"""
Test handle_hooks with invalid folder path
"""
result = handle_hooks("before", "./invalid_folder", self.source_folder)
self.assertFalse(result)

def test_handle_hooks_called_with_invalid_mode(self):
"""
Test handle_hooks with invalid mode
"""
result = handle_hooks("random_mode.exe", self.hooks_folder, self.source_folder)
self.assertFalse(result)

@mock.patch("sys.stdout", new_callable=StringIO)
def test_handle_hooks_called_with_valid_folder(self, stdout):
"""
Test handle_hooks with valid folder path
"""
result = handle_hooks("before", self.hooks_folder, self.source_folder)
self.assertTrue(result)
self.assertIn("In before_test.sh", stdout.getvalue())