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

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

View File

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

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 PatchData
import software.software_config as cfg
import software.config as cfg
import software.utils as utils
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)