diff --git a/software/software/base.py b/software/software/base.py index d5c2d44e..695e6c0a 100644 --- a/software/software/base.py +++ b/software/software/base.py @@ -12,7 +12,7 @@ import sys import time import software.utils as utils -import software.software_config as cfg +import software.config as cfg import software.constants as constants from software.software_functions import LOG diff --git a/software/software/cmd/__init__.py b/software/software/cmd/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/software/software/cmd/api.py b/software/software/cmd/api.py deleted file mode 100644 index 86975882..00000000 --- a/software/software/cmd/api.py +++ /dev/null @@ -1,78 +0,0 @@ -# -# Copyright (c) 2023 Wind River Systems, Inc. -# -# SPDX-License-Identifier: Apache-2.0 -# - -""" -API console script for Unified Software Management -""" -import gc -import socket -from wsgiref import simple_server - -from oslo_log import log as logging -from software.api.app import setup_app - - -LOG = logging.getLogger(__name__) - -# todo(abailey): these need to be part of config -API_PORT = 5496 -# Limit socket blocking to 5 seconds to allow for thread to shutdown -API_SOCKET_TIMEOUT = 5.0 - - -class RestAPI(): - """The base WSGI application""" - def __init__(self): - self.app = setup_app() - self.running = False - - def __call__(self, environ, start_response): - return self.app(environ, start_response) - - -class MyHandler(simple_server.WSGIRequestHandler): - """Overridden WSGIReqestHandler""" - def address_string(self): - # In the future, we could provide a config option to allow - # reverse DNS lookups. - return self.client_address[0] - - -def main(): - """Main entry point for API""" - # todo(abailey): process configuration - host = "127.0.0.1" - port = API_PORT - - # todo(abailey): configure logging - LOG.info(" + Starting Unified Software Management API") - - try: - simple_server.WSGIServer.address_family = socket.AF_INET - wsgi = simple_server.make_server( - host, port, - RestAPI(), - handler_class=MyHandler - ) - wsgi.socket.settimeout(API_SOCKET_TIMEOUT) - - running = True - while running: # run until an exception is raised - wsgi.handle_request() - - # Call garbage collect after wsgi request is handled, - # to ensure any open file handles are closed in the case - # of an upload. - gc.collect() - except KeyboardInterrupt: - LOG.warning(" - Received Control C. Shutting down.") - except BaseException: # pylint: disable=broad-exception-caught - LOG.exception(" - Unhandled API exception") - LOG.info(" - Stopping Unified Software Management API") - - -if __name__ == "__main__": - main() diff --git a/software/software/cmd/shell.py b/software/software/cmd/shell.py deleted file mode 100644 index 66179ef2..00000000 --- a/software/software/cmd/shell.py +++ /dev/null @@ -1,40 +0,0 @@ -# -# Copyright (c) 2023 Wind River Systems, Inc. -# -# SPDX-License-Identifier: Apache-2.0 -# - -""" -Command Line Interface for Unified Software Management -""" - -import logging -import sys - -BASENAME = 'software' -commands = ('capabilities', 'info', 'bash_completion') -logger = logging.getLogger(__name__) - - -class SoftwareShell: - """CLI Shell""" - def main(self, argv): - """Parse and run the commands for this CLI""" - print(f"Under construction {argv}") - - -def main(): - """Main entry point for CLI""" - try: - SoftwareShell().main(sys.argv[1:]) - except KeyboardInterrupt: - print(f"... terminating {BASENAME} client", file=sys.stderr) - sys.exit(130) - except Exception as ex: # pylint: disable=broad-exception-caught - logger.debug(ex, exc_info=1) - print(f"ERROR: {ex}", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/software/software/config.py b/software/software/config.py index 8c0dc047..9b4ce131 100644 --- a/software/software/config.py +++ b/software/software/config.py @@ -4,7 +4,28 @@ Copyright (c) 2023 Wind River Systems, Inc. SPDX-License-Identifier: Apache-2.0 """ +import configparser +import io +import logging +import os +import socket + from oslo_config import cfg +import tsconfig.tsconfig as tsc + +import software.utils as utils +import software.constants as constants + +controller_mcast_group = None +agent_mcast_group = None +controller_port = 0 +agent_port = 0 +api_port = 0 +mgmt_if = None +nodetype = None +platform_conf_mtime = 0 +software_conf_mtime = 0 +software_conf = '/etc/software/software.conf' # setup a shareable config CONF = cfg.CONF @@ -41,3 +62,100 @@ pecan_opts = [ # register the configuration for this component CONF.register_opts(pecan_opts, group=PECAN_CONFIG_GROUP) + + +def read_config(): + global software_conf_mtime + global software_conf + + if software_conf_mtime == os.stat(software_conf).st_mtime: + # The file has not changed since it was last read + return + + defaults = { + 'controller_mcast_group': "239.1.1.3", + 'agent_mcast_group': "239.1.1.4", + 'api_port': "5493", + 'controller_port': "5494", + 'agent_port': "5495", + } + + global controller_mcast_group + global agent_mcast_group + global api_port + global controller_port + global agent_port + + config = configparser.ConfigParser(defaults) + + config.read(software_conf) + software_conf_mtime = os.stat(software_conf).st_mtime + + controller_mcast_group = config.get('runtime', + 'controller_multicast') + agent_mcast_group = config.get('runtime', 'agent_multicast') + + api_port = config.getint('runtime', 'api_port') + controller_port = config.getint('runtime', 'controller_port') + agent_port = config.getint('runtime', 'agent_port') + + # The platform.conf file has no section headers, which causes problems + # for ConfigParser. So we'll fake it out. + ini_str = '[platform_conf]\n' + open(tsc.PLATFORM_CONF_FILE, 'r').read() + ini_fp = io.StringIO(ini_str) + config.read_file(ini_fp) + + try: + value = str(config.get('platform_conf', 'nodetype')) + + global nodetype + nodetype = value + except configparser.Error: + logging.exception("Failed to read nodetype from config") + + +def get_mgmt_ip(): + # Check if initial config is complete + if not os.path.exists('/etc/platform/.initial_config_complete'): + return None + mgmt_hostname = socket.gethostname() + return utils.gethostbyname(mgmt_hostname) + + +# Because the software daemons are launched before manifests are +# applied, the content of some settings in platform.conf can change, +# such as the management interface. As such, we can't just directly +# use tsc.management_interface +# +def get_mgmt_iface(): + # Check if initial config is complete + if not os.path.exists(constants.INITIAL_CONFIG_COMPLETE_FLAG): + return None + + global mgmt_if + global platform_conf_mtime + + if mgmt_if is not None and \ + platform_conf_mtime == os.stat(tsc.PLATFORM_CONF_FILE).st_mtime: + # The platform.conf file hasn't been modified since we read it, + # so return the cached value. + return mgmt_if + + config = configparser.ConfigParser() + + # The platform.conf file has no section headers, which causes problems + # for ConfigParser. So we'll fake it out. + ini_str = '[platform_conf]\n' + open(tsc.PLATFORM_CONF_FILE, 'r').read() + ini_fp = io.StringIO(ini_str) + config.read_file(ini_fp) + + try: + value = str(config.get('platform_conf', 'management_interface')) + + mgmt_if = value + + platform_conf_mtime = os.stat(tsc.PLATFORM_CONF_FILE).st_mtime + except configparser.Error: + logging.exception("Failed to read management_interface from config") + return None + return mgmt_if diff --git a/software/software/software_agent.py b/software/software/software_agent.py index e5ca846d..61070704 100644 --- a/software/software/software_agent.py +++ b/software/software/software_agent.py @@ -18,7 +18,7 @@ import time from software import ostree_utils from software.software_functions import configure_logging from software.software_functions import LOG -import software.software_config as cfg +import software.config as cfg from software.base import PatchService from software.exceptions import OSTreeCommandFail import software.utils as utils diff --git a/software/software/software_config.py b/software/software/software_config.py deleted file mode 100644 index 2d2715d7..00000000 --- a/software/software/software_config.py +++ /dev/null @@ -1,124 +0,0 @@ -""" -Copyright (c) 2023 Wind River Systems, Inc. - -SPDX-License-Identifier: Apache-2.0 - -""" -import configparser -import io -import logging -import os -import socket - -import tsconfig.tsconfig as tsc - -import software.utils as utils -import software.constants as constants - -controller_mcast_group = None -agent_mcast_group = None -controller_port = 0 -agent_port = 0 -api_port = 0 -mgmt_if = None -nodetype = None -platform_conf_mtime = 0 -software_conf_mtime = 0 -software_conf = '/etc/software/software.conf' - - -def read_config(): - global software_conf_mtime - global software_conf - - if software_conf_mtime == os.stat(software_conf).st_mtime: - # The file has not changed since it was last read - return - - defaults = { - 'controller_mcast_group': "239.1.1.3", - 'agent_mcast_group': "239.1.1.4", - 'api_port': "5493", - 'controller_port': "5494", - 'agent_port': "5495", - } - - global controller_mcast_group - global agent_mcast_group - global api_port - global controller_port - global agent_port - - config = configparser.ConfigParser(defaults) - - config.read(software_conf) - software_conf_mtime = os.stat(software_conf).st_mtime - - controller_mcast_group = config.get('runtime', - 'controller_multicast') - agent_mcast_group = config.get('runtime', 'agent_multicast') - - api_port = config.getint('runtime', 'api_port') - controller_port = config.getint('runtime', 'controller_port') - agent_port = config.getint('runtime', 'agent_port') - - # The platform.conf file has no section headers, which causes problems - # for ConfigParser. So we'll fake it out. - ini_str = '[platform_conf]\n' + open(tsc.PLATFORM_CONF_FILE, 'r').read() - ini_fp = io.StringIO(ini_str) - config.read_file(ini_fp) - - try: - value = str(config.get('platform_conf', 'nodetype')) - - global nodetype - nodetype = value - except configparser.Error: - logging.exception("Failed to read nodetype from config") - - -def get_mgmt_ip(): - # Check if initial config is complete - if not os.path.exists('/etc/platform/.initial_config_complete'): - return None - mgmt_hostname = socket.gethostname() - return utils.gethostbyname(mgmt_hostname) - - -# Because the software daemons are launched before manifests are -# applied, the content of some settings in platform.conf can change, -# such as the management interface. As such, we can't just directly -# use tsc.management_interface -# -def get_mgmt_iface(): - # Check if initial config is complete - if not os.path.exists(constants.INITIAL_CONFIG_COMPLETE_FLAG): - return None - - global mgmt_if - global platform_conf_mtime - - if mgmt_if is not None and \ - platform_conf_mtime == os.stat(tsc.PLATFORM_CONF_FILE).st_mtime: - # The platform.conf file hasn't been modified since we read it, - # so return the cached value. - return mgmt_if - - config = configparser.ConfigParser() - - # The platform.conf file has no section headers, which causes problems - # for ConfigParser. So we'll fake it out. - ini_str = '[platform_conf]\n' + open(tsc.PLATFORM_CONF_FILE, 'r').read() - ini_fp = io.StringIO(ini_str) - config.read_file(ini_fp) - - try: - value = str(config.get('platform_conf', 'management_interface')) - - mgmt_if = value - - platform_conf_mtime = os.stat(tsc.PLATFORM_CONF_FILE).st_mtime - except configparser.Error: - logging.exception("Failed to read management_interface from config") - return None - return mgmt_if diff --git a/software/software/software_controller.py b/software/software/software_controller.py index 8dff14d9..be3dd77a 100644 --- a/software/software/software_controller.py +++ b/software/software/software_controller.py @@ -57,7 +57,7 @@ from software.software_functions import patch_dir from software.software_functions import repo_root_dir from software.software_functions import PatchData -import software.software_config as cfg +import software.config as cfg import software.utils as utils import software.messages as messages diff --git a/software/software/tests/cmd/__init__.py b/software/software/tests/cmd/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/software/software/tests/cmd/test_api.py b/software/software/tests/cmd/test_api.py deleted file mode 100644 index 1009872e..00000000 --- a/software/software/tests/cmd/test_api.py +++ /dev/null @@ -1,33 +0,0 @@ -# -# Copyright (c) 2023 Wind River Systems, Inc. -# -# SPDX-License-Identifier: Apache-2.0 -# -"""Unit tests for software.cmd.api""" - -# standard imports -import logging -from unittest import mock -from wsgiref.simple_server import WSGIServer - -# third-party libraries -from oslo_log import fixture as log_fixture -import testtools - -# local imports -from software.cmd import api - - -class SoftwareCmdAPITestCase(testtools.TestCase): - """Unit tests for software.cmd.api""" - - @mock.patch.object(WSGIServer, 'handle_request') - def test_main(self, mock_handle_request): - """Test main method""" - # Info and Warning logs are expected for this unit test. - # 'ERROR' logs are not expected. - self.useFixture( - log_fixture.SetLogLevel(['software'], logging.ERROR) - ) - mock_handle_request.side_effect = KeyboardInterrupt - api.main() diff --git a/software/software/tests/cmd/test_shell.py b/software/software/tests/cmd/test_shell.py deleted file mode 100644 index 24f36ef2..00000000 --- a/software/software/tests/cmd/test_shell.py +++ /dev/null @@ -1,24 +0,0 @@ -# -# Copyright (c) 2023 Wind River Systems, Inc. -# -# SPDX-License-Identifier: Apache-2.0 -# -"""Unit tests for shell.py""" - -# standard imports -from unittest import mock - -# third party imports -import testtools - -# local imports -from software.cmd import shell - - -class SoftwareShellTestCase(testtools.TestCase): - """Unit tests for shell""" - - @mock.patch('sys.argv', ['']) - def test_no_args(self): - """Test main method with no args""" - shell.main() diff --git a/software/software/tests/test_software_client.py b/software/software/tests/test_software_client.py new file mode 100644 index 00000000..7bfbf59f --- /dev/null +++ b/software/software/tests/test_software_client.py @@ -0,0 +1,155 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# +# Copyright (c) 2023 Wind River Systems, Inc. +# + +import json +import os +import sys +import testtools +from unittest import mock + +from software import software_client + + +API_PORT = "5493" +URL_PREFIX = "http://127.0.0.1:" + API_PORT + "/software" + +FAKE_SW_VERSION = "1.2.3" +PATCH_FLAG_NO = "N" +PATCH_FLAG_YES = "Y" +STATE_APPLIED = "Applied" +STATE_AVAILABLE = "Available" +STATE_NA = "n/a" +STATUS_DEV = "DEV" + +FAKE_PATCH_ID_1 = "PATCH_1" +FAKE_PATCH_1_META = { + "apply_active_release_only": "", + "description": "Patch 1 description", + "install_instructions": "Patch 1 instructions", + "patchstate": STATE_NA, + "reboot_required": PATCH_FLAG_YES, + "repostate": STATE_APPLIED, + "requires": [], + "status": STATUS_DEV, + "summary": "Patch 1 summary", + "sw_version": FAKE_SW_VERSION, + "unremovable": PATCH_FLAG_NO, + "warnings": "Patch 1 warnings", +} + +FAKE_PATCH_ID_2 = "PATCH_2" +FAKE_PATCH_2_META = { + "apply_active_release_only": "", + "description": "Patch 2 description", + "install_instructions": "Patch 2 instructions", + "patchstate": STATE_AVAILABLE, + "reboot_required": PATCH_FLAG_NO, + "repostate": STATE_AVAILABLE, + "requires": [FAKE_PATCH_ID_1], + "status": STATUS_DEV, + "summary": "Patch 2 summary", + "sw_version": FAKE_SW_VERSION, + "unremovable": PATCH_FLAG_NO, + "warnings": "Patch 2 warnings", +} + + +class FakeResponse(object): + """This is used to mock a requests.get result""" + def __init__(self, json_data, status_code): + self.json_data = json_data + self.status_code = status_code + self.text = json.dumps(json_data) + + def json(self): + return self.json_data + + +class SoftwareClientTestCase(testtools.TestCase): + PROG = "software" + + MOCK_ENV = { + 'OS_AUTH_URL': 'FAKE_OS_AUTH_URL', + 'OS_PROJECT_NAME': 'FAKE_OS_PROJECT_NAME', + 'OS_PROJECT_DOMAIN_NAME': 'FAKE_OS_PROJECT_DOMAIN_NAME', + 'OS_USERNAME': 'FAKE_OS_USERNAME', + 'OS_PASSWORD': 'FAKE_OS_PASSWORD', + 'OS_USER_DOMAIN_NAME': 'FAKE_OS_USER_DOMAIN_NAME', + 'OS_REGION_NAME': 'FAKE_OS_REGION_NAME', + 'OS_INTERFACE': 'FAKE_OS_INTERFACE' + } + + # mock_map is populated by the setUp method + mock_map = {} + + def setUp(self): + super(SoftwareClientTestCase, self).setUp() + + def _mock_requests_get(*args, **kwargs): + key = args[0] + _ = kwargs # kwargs is unused + # if the key is not found in the mock_map + # we return a 404 (not found) + return self.mock_map.get(key, + FakeResponse(None, 404)) + + patcher = mock.patch( + 'requests.get', + side_effect=_mock_requests_get) + self.mock_requests_get = patcher.start() + self.addCleanup(patcher.stop) + + +class SoftwareClientNonRootMixin(object): + """ + This Mixin Requires self.MOCK_ENV + + Disable printing to stdout + + Every client call invokes exit which raises SystemExit + This asserts that happens. + """ + + def _test_method(self, shell_args=None): + with mock.patch.dict(os.environ, self.MOCK_ENV): + with mock.patch.object(sys, 'argv', shell_args): + # mock 'print' so running unit tests will + # not print to the tox output + with mock.patch('builtins.print'): + # Every client invocation invokes exit + # which raises SystemExit + self.assertRaises(SystemExit, + software_client.main) + + +class SoftwareClientHelpTestCase(SoftwareClientTestCase, SoftwareClientNonRootMixin): + """Test the sw-patch CLI calls that invoke 'help' + + 'check_for_os_region_name' is mocked to help determine + which code path is used since many code paths can short + circuit and invoke 'help' in failure cases. + """ + + @mock.patch('software.software_client.check_for_os_region_name') + def test_main_no_args_calls_help(self, mock_check): + """When no arguments are called, this should invoke print_help""" + shell_args = [self.PROG, ] + self._test_method(shell_args=shell_args) + mock_check.assert_not_called() + + @mock.patch('software.software_client.check_for_os_region_name') + def test_main_help(self, mock_check): + """When no arguments are called, this should invoke print_help""" + shell_args = [self.PROG, "--help"] + self._test_method(shell_args=shell_args) + mock_check.assert_called() + + @mock.patch('software.software_client.check_for_os_region_name') + def test_main_invalid_action_calls_help(self, mock_check): + """invalid args should invoke print_help""" + shell_args = [self.PROG, "invalid_arg"] + self._test_method(shell_args=shell_args) + mock_check.assert_called() diff --git a/software/software/tests/test_software_controller_messages.py b/software/software/tests/test_software_controller_messages.py new file mode 100644 index 00000000..91cb4f15 --- /dev/null +++ b/software/software/tests/test_software_controller_messages.py @@ -0,0 +1,161 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# +# Copyright (c) 2023 Wind River Systems, Inc. +# +import testtools +from unittest import mock + +from software.messages import PatchMessage +from software.software_controller import PatchMessageHello +from software.software_controller import PatchMessageHelloAck +from software.software_controller import PatchMessageSyncReq +from software.software_controller import PatchMessageSyncComplete +from software.software_controller import PatchMessageHelloAgent +from software.software_controller import PatchMessageSendLatestFeedCommit +from software.software_controller import PatchMessageHelloAgentAck +from software.software_controller import PatchMessageQueryDetailed +from software.software_controller import PatchMessageQueryDetailedResp +from software.software_controller import PatchMessageAgentInstallReq +from software.software_controller import PatchMessageAgentInstallResp +from software.software_controller import PatchMessageDropHostReq + + +FAKE_AGENT_ADDRESS = "127.0.0.1" +FAKE_AGENT_MCAST_GROUP = "239.1.1.4" +FAKE_CONTROLLER_ADDRESS = "127.0.0.1" +FAKE_HOST_IP = "10.10.10.2" +FAKE_OSTREE_FEED_COMMIT = "12345" + + +class FakeSoftwareController(object): + + def __init__(self): + self.agent_address = FAKE_AGENT_ADDRESS + self.allow_insvc_softwareing = True + self.controller_address = FAKE_CONTROLLER_ADDRESS + self.controller_neighbours = {} + self.hosts = {} + self.interim_state = {} + self.latest_feed_commit = FAKE_OSTREE_FEED_COMMIT + self.patch_op_counter = 0 + self.sock_in = None + self.sock_out = None + + # mock all the lock objects + self.controller_neighbours_lock = mock.Mock() + self.hosts_lock = mock.Mock() + self.software_data_lock = mock.Mock() + self.socket_lock = mock.Mock() + + # mock the software data + self.base_pkgdata = mock.Mock() + self.software_data = mock.Mock() + + def check_patch_states(self): + pass + + def drop_host(self, host_ip, sync_nbr=True): + pass + + def sync_from_nbr(self, host): + pass + + +class SoftwareControllerMessagesTestCase(testtools.TestCase): + + message_classes = [ + PatchMessageHello, + PatchMessageHelloAck, + PatchMessageSyncReq, + PatchMessageSyncComplete, + PatchMessageHelloAgent, + PatchMessageSendLatestFeedCommit, + PatchMessageHelloAgentAck, + PatchMessageQueryDetailed, + PatchMessageQueryDetailedResp, + PatchMessageAgentInstallReq, + PatchMessageAgentInstallResp, + PatchMessageDropHostReq, + ] + + def test_message_class_creation(self): + for message_class in SoftwareControllerMessagesTestCase.message_classes: + test_obj = message_class() + self.assertIsNotNone(test_obj) + self.assertIsInstance(test_obj, PatchMessage) + + @mock.patch('software.software_controller.pc', FakeSoftwareController()) + def test_message_class_encode(self): + """'encode' method populates self.message""" + # mock the global software_controller 'pc' variable used by encode + + # PatchMessageQueryDetailedResp does not support 'encode' + # so it can be executed, but it will not change the message + excluded = [ + PatchMessageQueryDetailedResp + ] + for message_class in SoftwareControllerMessagesTestCase.message_classes: + test_obj = message_class() + # message variable should be empty dict (ie: False) + self.assertFalse(test_obj.message) + test_obj.encode() + # message variable no longer empty (ie: True) + if message_class not in excluded: + self.assertTrue(test_obj.message) + # decode one message into another + test_obj2 = message_class() + test_obj2.decode(test_obj.message) + # decode does not populate 'message' so nothing to compare + + @mock.patch('software.software_controller.pc', FakeSoftwareController()) + @mock.patch('software.config.agent_mcast_group', FAKE_AGENT_MCAST_GROUP) + def test_message_class_send(self): + """'send' writes to a socket""" + mock_sock = mock.Mock() + + # socket sendto and sendall are not called by: + # PatchMessageHelloAgentAck + # PatchMessageQueryDetailedResp + # PatchMessageAgentInstallResp, + + send_to = [ + PatchMessageHello, + PatchMessageHelloAck, + PatchMessageSyncReq, + PatchMessageSyncComplete, + PatchMessageHelloAgent, + PatchMessageSendLatestFeedCommit, + PatchMessageAgentInstallReq, + PatchMessageDropHostReq, + ] + send_all = [ + PatchMessageQueryDetailed, + ] + + for message_class in SoftwareControllerMessagesTestCase.message_classes: + mock_sock.reset_mock() + test_obj = message_class() + test_obj.send(mock_sock) + if message_class in send_to: + mock_sock.sendto.assert_called() + if message_class in send_all: + mock_sock.sendall.assert_called() + + @mock.patch('software.software_controller.pc', FakeSoftwareController()) + def test_message_class_handle(self): + """'handle' method tests""" + addr = [FAKE_CONTROLLER_ADDRESS, ] # addr is a list + mock_sock = mock.Mock() + special_setup = { + PatchMessageDropHostReq: ('ip', FAKE_HOST_IP), + } + + for message_class in SoftwareControllerMessagesTestCase.message_classes: + mock_sock.reset_mock() + test_obj = message_class() + # some classes require special setup + special = special_setup.get(message_class) + if special: + setattr(test_obj, special[0], special[1]) + test_obj.handle(mock_sock, addr)