Update unit tests for new software component

- cmd files are replaced by software_client
 - software_config.py is renamed to config.py and includes
the previous config setup.
 - unit tests from sw-patch are being migrated here

Story: 2010676
Task: 47917
Signed-off-by: Al Bailey <al.bailey@windriver.com>
Change-Id: I886b4abd63a9b7057efd2b6440211a9c1f06f6f3
This commit is contained in:
Al Bailey 2023-04-27 14:49:11 +00:00
parent 4624457333
commit 31366985ab
13 changed files with 437 additions and 302 deletions

View File

@ -12,7 +12,7 @@ import sys
import time import time
import software.utils as utils import software.utils as utils
import software.software_config as cfg import software.config as cfg
import software.constants as constants import software.constants as constants
from software.software_functions import LOG from software.software_functions import LOG

View File

@ -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()

View File

@ -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()

View File

@ -4,7 +4,28 @@ Copyright (c) 2023 Wind River Systems, Inc.
SPDX-License-Identifier: Apache-2.0 SPDX-License-Identifier: Apache-2.0
""" """
import configparser
import io
import logging
import os
import socket
from oslo_config import cfg 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 # setup a shareable config
CONF = cfg.CONF CONF = cfg.CONF
@ -41,3 +62,100 @@ pecan_opts = [
# register the configuration for this component # register the configuration for this component
CONF.register_opts(pecan_opts, group=PECAN_CONFIG_GROUP) 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

View File

@ -18,7 +18,7 @@ import time
from software import ostree_utils from software import ostree_utils
from software.software_functions import configure_logging from software.software_functions import configure_logging
from software.software_functions import LOG from software.software_functions import LOG
import software.software_config as cfg import software.config as cfg
from software.base import PatchService from software.base import PatchService
from software.exceptions import OSTreeCommandFail from software.exceptions import OSTreeCommandFail
import software.utils as utils import software.utils as utils

View File

@ -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

View File

@ -57,7 +57,7 @@ from software.software_functions import patch_dir
from software.software_functions import repo_root_dir from software.software_functions import repo_root_dir
from software.software_functions import PatchData from software.software_functions import PatchData
import software.software_config as cfg import software.config as cfg
import software.utils as utils import software.utils as utils
import software.messages as messages import software.messages as messages

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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)