diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index c981dbd56..9e6f35323 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@v2.3.4 - name: Run tests shell: bash {0} - run: testing/test_baseline + run: testing/baseline/test_baseline testrun_tests: name: Tests @@ -27,7 +27,7 @@ jobs: uses: actions/checkout@v2.3.4 - name: Run tests shell: bash {0} - run: testing/test_tests + run: testing/tests/test_tests pylint: name: Pylint runs-on: ubuntu-22.04 @@ -37,4 +37,4 @@ jobs: uses: actions/checkout@v2.3.4 - name: Run tests shell: bash {0} - run: testing/test_pylint + run: testing/pylint/test_pylint diff --git a/cmd/start b/bin/testrun similarity index 72% rename from cmd/start rename to bin/testrun index 64ac197eb..9281c1ac6 100755 --- a/cmd/start +++ b/bin/testrun @@ -15,23 +15,26 @@ # limitations under the License. if [[ "$EUID" -ne 0 ]]; then - echo "Must run as root. Use sudo cmd/start" + echo "Must run as root. Use sudo testrun" exit 1 fi +# TODO: Obtain TESTRUNPATH from user environment variables +# TESTRUNPATH="/home/boddey/Desktop/test-run" +# cd $TESTRUNPATH + # Ensure that /var/run/netns folder exists -mkdir -p /var/run/netns +sudo mkdir -p /var/run/netns -# Clear up existing runtime files -rm -rf runtime +# Create device folder if it doesn't exist +mkdir -p local/devices -# Check if python modules exist. Install if not -[ ! -d "venv" ] && cmd/install +# Check if Python modules exist. Install if not +[ ! -d "venv" ] && sudo cmd/install # Activate Python virtual environment source venv/bin/activate -# TODO: Execute python code # Set the PYTHONPATH to include the "src" directory export PYTHONPATH="$PWD/framework/python/src" python -u framework/python/src/core/test_runner.py $@ diff --git a/framework/python/src/api/api.py b/framework/python/src/api/api.py new file mode 100644 index 000000000..d877a5b33 --- /dev/null +++ b/framework/python/src/api/api.py @@ -0,0 +1,197 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from fastapi import FastAPI, APIRouter, Response, Request, status +import json +from json import JSONDecodeError +import psutil +import threading +import uvicorn + +from common import logger +from common.device import Device + +LOGGER = logger.get_logger("api") + +DEVICE_MAC_ADDR_KEY = "mac_addr" +DEVICE_MANUFACTURER_KEY = "manufacturer" +DEVICE_MODEL_KEY = "model" + +class Api: + """Provide REST endpoints to manage Test Run""" + + def __init__(self, test_run): + + self._test_run = test_run + self._name = "TestRun API" + self._router = APIRouter() + + self._session = self._test_run.get_session() + + self._router.add_api_route("/system/interfaces", self.get_sys_interfaces) + self._router.add_api_route("/system/config", self.post_sys_config, + methods=["POST"]) + self._router.add_api_route("/system/config", self.get_sys_config) + self._router.add_api_route("/system/start", self.start_test_run, + methods=["POST"]) + self._router.add_api_route("/system/stop", self.stop_test_run, + methods=["POST"]) + self._router.add_api_route("/system/status", self.get_status) + + self._router.add_api_route("/devices", self.get_devices) + self._router.add_api_route("/device", self.save_device, methods=["POST"]) + + self._app = FastAPI() + self._app.include_router(self._router) + + self._api_thread = threading.Thread(target=self._start, + name="Test Run API", + daemon=True) + + def start(self): + LOGGER.info("Starting API") + self._api_thread.start() + LOGGER.info("API waiting for requests") + + def _start(self): + uvicorn.run(self._app, log_config=None) + + def stop(self): + LOGGER.info("Stopping API") + + async def get_sys_interfaces(self): + addrs = psutil.net_if_addrs() + ifaces = [] + for iface in addrs: + ifaces.append(iface) + return ifaces + + async def post_sys_config(self, request: Request, response: Response): + try: + config = (await request.body()).decode("UTF-8") + config_json = json.loads(config) + self._session.set_config(config_json) + # Catch JSON Decode error etc + except JSONDecodeError: + response.status_code = status.HTTP_400_BAD_REQUEST + return self._generate_msg(False, "Invalid JSON received") + return self._session.get_config() + + async def get_sys_config(self): + return self._session.get_config() + + async def get_devices(self): + return self._session.get_device_repository() + + async def start_test_run(self, request: Request, response: Response): + + LOGGER.debug("Received start command") + + # Check request is valid + body = (await request.body()).decode("UTF-8") + body_json = None + + try: + body_json = json.loads(body) + except JSONDecodeError: + response.status_code = status.HTTP_400_BAD_REQUEST + return self._generate_msg(False, "Invalid JSON received") + + if "device" not in body_json or "mac_addr" not in body_json["device"]: + response.status_code = status.HTTP_400_BAD_REQUEST + return self._generate_msg(False, "Invalid request received") + + device = self._session.get_device(body_json["device"]["mac_addr"]) + + # Check Test Run is not already running + if self._test_run.get_session().get_status() != "Idle": + LOGGER.debug("Test Run is already running. Cannot start another instance") + response.status_code = status.HTTP_409_CONFLICT + return self._generate_msg(False, "Test Run is already running") + + # Check if requested device is known in the device repository + if device is None: + response.status_code = status.HTTP_404_NOT_FOUND + return self._generate_msg(False, "A device with that MAC address could not be found") + + # Check Test Run is able to start + if self._test_run.get_net_orc().check_config() is False: + response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + return self._generate_msg(False, "Configured interfaces are not ready for use. Ensure both interfaces are connected.") + + self._test_run.get_session().set_target_device(device) + LOGGER.info(f"Starting Test Run with device target {device.manufacturer} {device.model} with MAC address {device.mac_addr}") + + thread = threading.Thread(target=self._start_test_run, + name="Test Run") + thread.start() + return self._test_run.get_session().to_json() + + def _generate_msg(self, success, message): + msg_type = "success" + if not success: + msg_type = "error" + return json.loads('{"' + msg_type + '": "' + message + '"}') + + def _start_test_run(self): + self._test_run.start() + + async def stop_test_run(self): + LOGGER.debug("Received stop command. Stopping Test Run") + self._test_run.stop() + return self._generate_msg(True, "Test Run stopped") + + async def get_status(self): + return self._test_run.get_session().to_json() + + async def get_history(self): + LOGGER.info("Returning previous Test Runs to UI") + + async def save_device(self, request: Request, response: Response): + LOGGER.debug("Received device post request") + + try: + device_raw = (await request.body()).decode("UTF-8") + device_json = json.loads(device_raw) + + if not self._validate_device_json(device_json): + response.status_code = status.HTTP_400_BAD_REQUEST + return self._generate_msg(False, "Invalid request received") + + device = self._session.get_device(device_json.get(DEVICE_MAC_ADDR_KEY)) + if device is None: + # Create new device + device = Device() + device.mac_addr = device_json.get(DEVICE_MAC_ADDR_KEY) + response.status_code = status.HTTP_201_CREATED + + device.manufacturer = device_json.get(DEVICE_MANUFACTURER_KEY) + device.model = device_json.get(DEVICE_MODEL_KEY) + + self._session.save_device(device) + + return device + + # Catch JSON Decode error etc + except JSONDecodeError: + response.status_code = status.HTTP_400_BAD_REQUEST + return self._generate_msg(False, "Invalid JSON received") + + def _validate_device_json(self, json_obj): + if not (DEVICE_MAC_ADDR_KEY in json_obj and + DEVICE_MANUFACTURER_KEY in json_obj and + DEVICE_MODEL_KEY in json_obj + ): + return False + return True diff --git a/framework/python/src/core/device.py b/framework/python/src/common/device.py similarity index 88% rename from framework/python/src/core/device.py rename to framework/python/src/common/device.py index efce2dba1..b70099519 100644 --- a/framework/python/src/core/device.py +++ b/framework/python/src/common/device.py @@ -14,14 +14,14 @@ """Track device object information.""" -from net_orc.network_device import NetworkDevice from dataclasses import dataclass - @dataclass -class Device(NetworkDevice): +class Device(): """Represents a physical device and it's configuration.""" + mac_addr: str = None manufacturer: str = None model: str = None test_modules: str = None + ip_addr: str = None diff --git a/framework/python/src/common/session.py b/framework/python/src/common/session.py new file mode 100644 index 000000000..a0f6118ff --- /dev/null +++ b/framework/python/src/common/session.py @@ -0,0 +1,169 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Track testing status.""" + +import datetime +import json +import os + +NETWORK_KEY = 'network' +DEVICE_INTF_KEY = 'device_intf' +INTERNET_INTF_KEY = 'internet_intf' +RUNTIME_KEY = 'runtime' +MONITOR_PERIOD_KEY = 'monitor_period' +STARTUP_TIMEOUT_KEY = 'startup_timeout' +LOG_LEVEL_KEY = 'log_level' + +class TestRunSession(): + """Represents the current session of Test Run.""" + + def __init__(self, config_file): + self._status = 'Idle' + self._device = None + self._started = None + self._finished = None + self._tests = [] + + self._config_file = config_file + + self._config = self._get_default_config() + self._load_config() + + self._device_repository = [] + + def start(self): + self._status = 'Starting' + self._started = datetime.datetime.now() + + def get_started(self): + return self._started + + def get_finished(self): + return self._finished + + def _get_default_config(self): + return { + 'network': { + 'device_intf': '', + 'internet_intf': '' + }, + 'log_level': 'INFO', + 'startup_timeout': 60, + 'monitor_period': 30, + 'runtime': 120 + } + + def get_config(self): + return self._config + + def _load_config(self): + + if not os.path.isfile(self._config_file): + return + + with open(self._config_file, 'r', encoding='utf-8') as f: + config_file_json = json.load(f) + + # Network interfaces + if (NETWORK_KEY in config_file_json + and DEVICE_INTF_KEY in config_file_json.get(NETWORK_KEY) + and INTERNET_INTF_KEY in config_file_json.get(NETWORK_KEY)): + self._config[NETWORK_KEY][DEVICE_INTF_KEY] = config_file_json.get(NETWORK_KEY, {}).get(DEVICE_INTF_KEY) + self._config[NETWORK_KEY][INTERNET_INTF_KEY] = config_file_json.get(NETWORK_KEY, {}).get(INTERNET_INTF_KEY) + + if RUNTIME_KEY in config_file_json: + self._config[RUNTIME_KEY] = config_file_json.get(RUNTIME_KEY) + + if STARTUP_TIMEOUT_KEY in config_file_json: + self._config[STARTUP_TIMEOUT_KEY] = config_file_json.get(STARTUP_TIMEOUT_KEY) + + if MONITOR_PERIOD_KEY in config_file_json: + self._config[MONITOR_PERIOD_KEY] = config_file_json.get(MONITOR_PERIOD_KEY) + + if LOG_LEVEL_KEY in config_file_json: + self._config[LOG_LEVEL_KEY] = config_file_json.get(LOG_LEVEL_KEY) + + def _save_config(self): + with open(self._config_file, 'w', encoding='utf-8') as f: + f.write(json.dumps(self._config, indent=2)) + + def get_runtime(self): + return self._config.get(RUNTIME_KEY) + + def get_log_level(self): + return self._config.get(LOG_LEVEL_KEY) + + def get_device_interface(self): + return self._config.get(NETWORK_KEY, {}).get(DEVICE_INTF_KEY) + + def get_internet_interface(self): + return self._config.get(NETWORK_KEY, {}).get(INTERNET_INTF_KEY) + + def get_monitor_period(self): + return self._config.get(MONITOR_PERIOD_KEY) + + def get_startup_timeout(self): + return self._config.get(STARTUP_TIMEOUT_KEY) + + def set_config(self, config_json): + self._config = config_json + self._save_config() + + def set_target_device(self, device): + self._device = device + + def get_target_device(self): + return self._device + + def get_device_repository(self): + return self._device_repository + + def add_device(self, device): + self._device_repository.append(device) + + def get_device(self, mac_addr): + for device in self._device_repository: + if device.mac_addr == mac_addr: + return device + return None + + def save_device(self, device): + # TODO: We need to save the folder path of the device config + return + + def get_status(self): + return self._status + + def set_status(self, status): + self._status = status + + def get_tests(self): + return self._tests + + def reset(self): + self.set_status('Idle') + self.set_target_device(None) + self._tests = [] + self._started = None + self._finished = None + + def to_json(self): + return { + 'status': self.get_status(), + 'device': self.get_target_device(), + 'started': self.get_started(), + 'finished': self.get_finished(), + 'tests': self.get_tests() + } diff --git a/framework/python/src/core/test_runner.py b/framework/python/src/core/test_runner.py index 226f874cc..9962c3995 100644 --- a/framework/python/src/core/test_runner.py +++ b/framework/python/src/core/test_runner.py @@ -36,12 +36,14 @@ def __init__(self, config_file=None, validate=True, net_only=False, - single_intf=False): + single_intf=False, + no_ui=False): self._register_exits() self.test_run = TestRun(config_file=config_file, validate=validate, net_only=net_only, - single_intf=single_intf) + single_intf=single_intf, + no_ui=no_ui) def _register_exits(self): signal.signal(signal.SIGINT, self._exit_handler) @@ -62,10 +64,6 @@ def _exit_handler(self, signum, arg): # pylint: disable=unused-argument def stop(self, kill=False): self.test_run.stop(kill) - def start(self): - self.test_run.start() - LOGGER.info("Test Run has finished") - def parse_args(): parser = argparse.ArgumentParser( @@ -88,6 +86,10 @@ def parse_args(): parser.add_argument("--single-intf", action="store_true", help="Single interface mode (experimental)") + parser.add_argument("--no-ui", + default=False, + action="store_true", + help="Do not launch the user interface") parsed_args = parser.parse_known_args()[0] return parsed_args @@ -97,5 +99,5 @@ def parse_args(): runner = TestRunner(config_file=args.config_file, validate=not args.no_validate, net_only=args.net_only, - single_intf=args.single_intf) - runner.start() + single_intf=args.single_intf, + no_ui=args.no_ui) diff --git a/framework/python/src/core/testrun.py b/framework/python/src/core/testrun.py index a91736e95..6016fbfe7 100644 --- a/framework/python/src/core/testrun.py +++ b/framework/python/src/core/testrun.py @@ -20,31 +20,34 @@ Run using the provided command scripts in the cmd folder. E.g sudo cmd/start """ +import json import os import sys -import json import signal import time from common import logger, util +from common.device import Device +from common.session import TestRunSession +from api.api import Api +from net_orc.listener import NetworkEvent +from net_orc import network_orchestrator as net_orc +from test_orc import test_orchestrator as test_orc # Locate parent directory current_dir = os.path.dirname(os.path.realpath(__file__)) # Locate the test-run root directory, 4 levels, src->python->framework->test-run -root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_dir)))) - -from net_orc.listener import NetworkEvent -from test_orc import test_orchestrator as test_orc -from net_orc import network_orchestrator as net_orc -from device import Device +root_dir = os.path.dirname(os.path.dirname( + os.path.dirname(os.path.dirname(current_dir)))) LOGGER = logger.get_logger('test_run') -CONFIG_FILE = 'local/system.json' + +DEFAULT_CONFIG_FILE = 'local/system.json' EXAMPLE_CONFIG_FILE = 'local/system.json.example' -RUNTIME = 120 LOCAL_DEVICES_DIR = 'local/devices' RESOURCE_DEVICES_DIR = 'resources/devices' + DEVICE_CONFIG = 'device_config.json' DEVICE_MANUFACTURER = 'manufacturer' DEVICE_MODEL = 'model' @@ -59,77 +62,134 @@ class TestRun: # pylint: disable=too-few-public-methods """ def __init__(self, - config_file=CONFIG_FILE, + config_file, validate=True, net_only=False, - single_intf=False): - self._devices = [] + single_intf=False, + no_ui=False): + + if config_file is None: + self._config_file = self._get_config_abs(DEFAULT_CONFIG_FILE) + else: + self._config_file = self._get_config_abs(config_file) + self._net_only = net_only self._single_intf = single_intf + self._no_ui = no_ui # Catch any exit signals self._register_exits() - # Expand the config file to absolute pathing - config_file_abs = self._get_config_abs(config_file=config_file) + self._session = TestRunSession(config_file=self._config_file) + self._load_all_devices() self._net_orc = net_orc.NetworkOrchestrator( - config_file=config_file_abs, + session=self._session, validate=validate, single_intf = self._single_intf) + self._test_orc = test_orc.TestOrchestrator( + self._session, + self._net_orc) - self._test_orc = test_orc.TestOrchestrator(self._net_orc) + if self._no_ui: + self.start() + else: + self._api = Api(self) + self._api.start() + + # Hold until API ends + while True: + time.sleep(1) + + def _load_all_devices(self): + self._load_devices(device_dir=LOCAL_DEVICES_DIR) + self._load_devices(device_dir=RESOURCE_DEVICES_DIR) + return self.get_session().get_device_repository() + + def _load_devices(self, device_dir): + LOGGER.debug('Loading devices from ' + device_dir) + + util.run_command(f'chown -R {util.get_host_user()} {device_dir}') + + for device_folder in os.listdir(device_dir): + with open(os.path.join(device_dir, device_folder, DEVICE_CONFIG), + encoding='utf-8') as device_config_file: + device_config_json = json.load(device_config_file) + + device_manufacturer = device_config_json.get(DEVICE_MANUFACTURER) + device_model = device_config_json.get(DEVICE_MODEL) + mac_addr = device_config_json.get(DEVICE_MAC_ADDR) + test_modules = device_config_json.get(DEVICE_TEST_MODULES) + + device = Device(manufacturer=device_manufacturer, + model=device_model, + mac_addr=mac_addr, + test_modules=test_modules) + self.get_session().add_device(device) + LOGGER.debug(f'Loaded device {device.manufacturer} {device.model} with MAC address {device.mac_addr}') def start(self): - self._load_all_devices() + self._session.start() self._start_network() if self._net_only: LOGGER.info('Network only option configured, no tests will be run') - self._net_orc.listener.register_callback( + self.get_net_orc().listener.register_callback( self._device_discovered, [NetworkEvent.DEVICE_DISCOVERED] ) - self._net_orc.start_listener() + self.get_net_orc().start_listener() LOGGER.info('Waiting for devices on the network...') while True: - time.sleep(RUNTIME) + time.sleep(self._session.get_runtime()) else: self._test_orc.start() - self._net_orc.listener.register_callback( + self.get_net_orc().get_listener().register_callback( self._device_stable, [NetworkEvent.DEVICE_STABLE] ) - self._net_orc.listener.register_callback( + self.get_net_orc().get_listener().register_callback( self._device_discovered, [NetworkEvent.DEVICE_DISCOVERED] ) - self._net_orc.start_listener() + self.get_net_orc().start_listener() + self._set_status('Waiting for device') LOGGER.info('Waiting for devices on the network...') - time.sleep(RUNTIME) + time.sleep(self._session.get_runtime()) - if not (self._test_orc.test_in_progress() or self._net_orc.monitor_in_progress()): - LOGGER.info('Timed out whilst waiting for device or stopping due to test completion') + if not (self._test_orc.test_in_progress() or + self.get_net_orc().monitor_in_progress()): + LOGGER.info('''Timed out whilst waiting for + device or stopping due to test completion''') else: - while self._test_orc.test_in_progress() or self._net_orc.monitor_in_progress(): + while (self._test_orc.test_in_progress() or + self.get_net_orc().monitor_in_progress()): time.sleep(5) self.stop() def stop(self, kill=False): + self._set_status('Stopping') + + # Prevent discovering new devices whilst stopping + if self.get_net_orc().get_listener() is not None: + self.get_net_orc().get_listener().stop_listener() + self._stop_tests() self._stop_network(kill=kill) + self.get_session().reset() + def _register_exits(self): signal.signal(signal.SIGINT, self._exit_handler) signal.signal(signal.SIGTERM, self._exit_handler) @@ -146,54 +206,44 @@ def _exit_handler(self, signum, arg): # pylint: disable=unused-argument def _get_config_abs(self, config_file=None): if config_file is None: # If not defined, use relative pathing to local file - config_file = os.path.join(root_dir, CONFIG_FILE) + config_file = os.path.join(root_dir, self._config_file) # Expand the config file to absolute pathing return os.path.abspath(config_file) + def get_config_file(self): + return self._get_config_abs() + + def get_net_orc(self): + return self._net_orc + def _start_network(self): # Start the network orchestrator - self._net_orc.start() + if not self.get_net_orc().start(): + self.stop(kill=True) + sys.exit(1) def _stop_network(self, kill=False): - self._net_orc.stop(kill=kill) + self.get_net_orc().stop(kill=kill) def _stop_tests(self): self._test_orc.stop() - def _load_all_devices(self): - self._load_devices(device_dir=LOCAL_DEVICES_DIR) - self._load_devices(device_dir=RESOURCE_DEVICES_DIR) - - def _load_devices(self, device_dir): - LOGGER.debug('Loading devices from ' + device_dir) - - os.makedirs(device_dir, exist_ok=True) - util.run_command(f'chown -R {util.get_host_user()} {device_dir}') - - for device_folder in os.listdir(device_dir): - with open(os.path.join(device_dir, device_folder, DEVICE_CONFIG), - encoding='utf-8') as device_config_file: - device_config_json = json.load(device_config_file) - - device_manufacturer = device_config_json.get(DEVICE_MANUFACTURER) - device_model = device_config_json.get(DEVICE_MODEL) - mac_addr = device_config_json.get(DEVICE_MAC_ADDR) - test_modules = device_config_json.get(DEVICE_TEST_MODULES) - - device = Device(manufacturer=device_manufacturer, - model=device_model, - mac_addr=mac_addr, - test_modules=json.dumps(test_modules)) - self._devices.append(device) - def get_device(self, mac_addr): """Returns a loaded device object from the device mac address.""" - for device in self._devices: + for device in self._session.get_device_repository(): if device.mac_addr == mac_addr: return device + return None def _device_discovered(self, mac_addr): + + if self.get_session().get_target_device() is not None: + if mac_addr != self.get_session().get_target_device().mac_addr: + # Ignore discovered device + return + + self._set_status('Identifying device') device = self.get_device(mac_addr) if device is not None: LOGGER.info( @@ -207,4 +257,12 @@ def _device_discovered(self, mac_addr): def _device_stable(self, mac_addr): device = self.get_device(mac_addr) LOGGER.info(f'Device with mac address {mac_addr} is ready for testing.') + self._set_status('In progress') self._test_orc.run_test_modules(device) + self._set_status('Complete') + + def _set_status(self, status): + self._session.set_status(status) + + def get_session(self): + return self._session diff --git a/framework/python/src/net_orc/listener.py b/framework/python/src/net_orc/listener.py index 4f8e1961f..83805f908 100644 --- a/framework/python/src/net_orc/listener.py +++ b/framework/python/src/net_orc/listener.py @@ -31,8 +31,9 @@ class Listener: """Methods to start and stop the network listener.""" - def __init__(self, device_intf): - self._device_intf = device_intf + def __init__(self, session): + self._session = session + self._device_intf = self._session.get_device_interface() self._device_intf_mac = get_if_hwaddr(self._device_intf) self._sniffer = AsyncSniffer(iface=self._device_intf, @@ -47,7 +48,8 @@ def start_listener(self): def stop_listener(self): """Stop sniffing packets on the device interface.""" - self._sniffer.stop() + if self._sniffer.running: + self._sniffer.stop() def is_running(self): """Determine whether the sniffer is running.""" diff --git a/framework/python/src/net_orc/network_orchestrator.py b/framework/python/src/net_orc/network_orchestrator.py index 499ce954b..7d550d4ae 100644 --- a/framework/python/src/net_orc/network_orchestrator.py +++ b/framework/python/src/net_orc/network_orchestrator.py @@ -11,9 +11,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + """Network orchestrator is responsible for managing all of the virtual network services""" -import getpass import ipaddress import json import os @@ -23,57 +23,42 @@ import sys import docker from docker.types import Mount -from common import logger -from common import util +from common import logger, util from net_orc.listener import Listener -from net_orc.network_device import NetworkDevice from net_orc.network_event import NetworkEvent from net_orc.network_validator import NetworkValidator from net_orc.ovs_control import OVSControl from net_orc.ip_control import IPControl LOGGER = logger.get_logger('net_orc') -CONFIG_FILE = 'local/system.json' -EXAMPLE_CONFIG_FILE = 'local/system.json.example' RUNTIME_DIR = 'runtime' TEST_DIR = 'test' -MONITOR_PCAP = 'monitor.pcap' NET_DIR = 'runtime/network' NETWORK_MODULES_DIR = 'modules/network' + +MONITOR_PCAP = 'monitor.pcap' NETWORK_MODULE_METADATA = 'conf/module_config.json' + DEVICE_BRIDGE = 'tr-d' INTERNET_BRIDGE = 'tr-c' PRIVATE_DOCKER_NET = 'tr-private-net' CONTAINER_NAME = 'network_orchestrator' -RUNTIME_KEY = 'runtime' -MONITOR_PERIOD_KEY = 'monitor_period' -STARTUP_TIMEOUT_KEY = 'startup_timeout' -DEFAULT_STARTUP_TIMEOUT = 60 -DEFAULT_RUNTIME = 1200 -DEFAULT_MONITOR_PERIOD = 300 - class NetworkOrchestrator: """Manage and controls a virtual testing network.""" def __init__(self, - config_file=CONFIG_FILE, + session, validate=True, single_intf=False): - self._runtime = DEFAULT_RUNTIME - self._startup_timeout = DEFAULT_STARTUP_TIMEOUT - self._monitor_period = DEFAULT_MONITOR_PERIOD + self._session = session self._monitor_in_progress = False - - self._int_intf = None - self._dev_intf = None + self._validate = validate self._single_intf = single_intf - self.listener = None + self._listener = None self._net_modules = [] - self._devices = [] - self.validate = validate self._path = os.path.dirname( os.path.dirname( @@ -83,8 +68,7 @@ def __init__(self, self.validator = NetworkValidator() shutil.rmtree(os.path.join(os.getcwd(), NET_DIR), ignore_errors=True) self.network_config = NetworkConfig() - self.load_config(config_file) - self._ovs = OVSControl() + self._ovs = OVSControl(self._session) self._ip_ctrl = IPControl() def start(self): @@ -102,23 +86,38 @@ def start(self): self.start_network() + return True + + def check_config(self): + + if not util.interface_exists(self._session.get_internet_interface()) or not util.interface_exists( + self._session.get_device_interface()): + LOGGER.error('Configured interfaces are not ready for use. ' + + 'Ensure both interfaces are connected.') + return False + return True + def start_network(self): """Start the virtual testing network.""" LOGGER.info('Starting network') self.build_network_modules() + self.create_net() self.start_network_services() - if self.validate: + if self._validate: # Start the validator after network is ready self.validator.start() # Get network ready (via Network orchestrator) LOGGER.debug('Network is ready') + def get_listener(self): + return self._listener + def start_listener(self): - self.listener.start_listener() + self.get_listener().start_listener() def stop(self, kill=False): """Stop the network orchestrator.""" @@ -136,43 +135,35 @@ def stop_network(self, kill=False): self.stop_networking_services(kill=kill) self.restore_net() - def load_config(self, config_file=None): - if config_file is None: - # If not defined, use relative pathing to local file - self._config_file = os.path.join(self._path, CONFIG_FILE) - else: - # If defined, use as provided - self._config_file = config_file - - if not os.path.isfile(self._config_file): - LOGGER.error('Configuration file is not present at ' + config_file) - LOGGER.info('An example is present in ' + EXAMPLE_CONFIG_FILE) - sys.exit(1) + def _device_discovered(self, mac_addr): - LOGGER.info('Loading config file: ' + os.path.abspath(self._config_file)) - with open(self._config_file, encoding='UTF-8') as config_json_file: - config_json = json.load(config_json_file) - self.import_config(config_json) + device = self._session.get_device(mac_addr) - def _device_discovered(self, mac_addr): + if self._session.get_target_device() is not None: + if mac_addr != self._session.get_target_device().mac_addr: + # Ignore discovered device + return self._monitor_in_progress = True LOGGER.debug( f'Discovered device {mac_addr}. Waiting for device to obtain IP') - device = self._get_device(mac_addr=mac_addr) + if device is None: + LOGGER.debug(f'Device with MAC address {mac_addr} does not exist in device repository') + # Ignore device if not registered + return device_runtime_dir = os.path.join(RUNTIME_DIR, TEST_DIR, - device.mac_addr.replace(':', '')) + mac_addr.replace(':', '')) os.makedirs(device_runtime_dir) util.run_command(f'chown -R {self._host_user} {device_runtime_dir}') - packet_capture = sniff(iface=self._dev_intf, - timeout=self._startup_timeout, + packet_capture = sniff(iface=self._session.get_device_interface(), + timeout=self._session.get_startup_timeout(), stop_filter=self._device_has_ip) wrpcap( - os.path.join(RUNTIME_DIR, TEST_DIR, device.mac_addr.replace(':', ''), + os.path.join(RUNTIME_DIR, TEST_DIR, mac_addr.replace(':', ''), 'startup.pcap'), packet_capture) if device.ip_addr is None: @@ -189,49 +180,35 @@ def monitor_in_progress(self): return self._monitor_in_progress def _device_has_ip(self, packet): - device = self._get_device(mac_addr=packet.src) + device = self._session.get_device(mac_addr=packet.src) if device is None or device.ip_addr is None: return False return True def _dhcp_lease_ack(self, packet): mac_addr = packet[BOOTP].chaddr.hex(':')[0:17] - device = self._get_device(mac_addr=mac_addr) + device = self._session.get_device(mac_addr=mac_addr) + + # Ignore devices that are not registered + if device is None: + return + + # TODO: Check if device is None device.ip_addr = packet[BOOTP].yiaddr def _start_device_monitor(self, device): """Start a timer until the steady state has been reached and callback the steady state method for this device.""" LOGGER.info(f'Monitoring device with mac addr {device.mac_addr} ' - f'for {str(self._monitor_period)} seconds') + f'for {str(self._session.get_monitor_period())} seconds') - packet_capture = sniff(iface=self._dev_intf, timeout=self._monitor_period) + packet_capture = sniff(iface=self._session.get_device_interface(), timeout=self._session.get_monitor_period()) wrpcap( os.path.join(RUNTIME_DIR, TEST_DIR, device.mac_addr.replace(':', ''), 'monitor.pcap'), packet_capture) self._monitor_in_progress = False - self.listener.call_callback(NetworkEvent.DEVICE_STABLE, device.mac_addr) - - def _get_device(self, mac_addr): - for device in self._devices: - if device.mac_addr == mac_addr: - return device - - device = NetworkDevice(mac_addr=mac_addr) - self._devices.append(device) - return device - - def import_config(self, json_config): - self._int_intf = json_config['network']['internet_intf'] - self._dev_intf = json_config['network']['device_intf'] - - if RUNTIME_KEY in json_config: - self._runtime = json_config[RUNTIME_KEY] - if STARTUP_TIMEOUT_KEY in json_config: - self._startup_timeout = json_config[STARTUP_TIMEOUT_KEY] - if MONITOR_PERIOD_KEY in json_config: - self._monitor_period = json_config[MONITOR_PERIOD_KEY] + self.get_listener().call_callback(NetworkEvent.DEVICE_STABLE, device.mac_addr) def _check_network_services(self): LOGGER.debug('Checking network modules...') @@ -278,30 +255,30 @@ def _ci_pre_network_create(self): """ self._ethmac = subprocess.check_output( - f'cat /sys/class/net/{self._int_intf}/address', + f'cat /sys/class/net/{self._session.get_internet_interface()}/address', shell=True).decode('utf-8').strip() self._gateway = subprocess.check_output( 'ip route | head -n 1 | awk \'{print $3}\'', shell=True).decode('utf-8').strip() self._ipv4 = subprocess.check_output( - f'ip a show {self._int_intf} | grep \"inet \" | awk \'{{print $2}}\'', + f'ip a show {self._session.get_internet_interface()} | grep \"inet \" | awk \'{{print $2}}\'', shell=True).decode('utf-8').strip() self._ipv6 = subprocess.check_output( - f'ip a show {self._int_intf} | grep inet6 | awk \'{{print $2}}\'', + f'ip a show {self._session.get_internet_interface()} | grep inet6 | awk \'{{print $2}}\'', shell=True).decode('utf-8').strip() self._brd = subprocess.check_output( - f'ip a show {self._int_intf} | grep \"inet \" | awk \'{{print $4}}\'', + f'ip a show {self._session.get_internet_interface()} | grep \"inet \" | awk \'{{print $4}}\'', shell=True).decode('utf-8').strip() def _ci_post_network_create(self): """ Restore network connection in CI environment """ LOGGER.info('post cr') - util.run_command(f'ip address del {self._ipv4} dev {self._int_intf}') - util.run_command(f'ip -6 address del {self._ipv6} dev {self._int_intf}') + util.run_command(f'ip address del {self._ipv4} dev {self._session.get_internet_interface()}') + util.run_command(f'ip -6 address del {self._ipv6} dev {self._session.get_internet_interface()}') util.run_command( - f'ip link set dev {self._int_intf} address 00:B0:D0:63:C2:26') - util.run_command(f'ip addr flush dev {self._int_intf}') - util.run_command(f'ip addr add dev {self._int_intf} 0.0.0.0') + f'ip link set dev {self._session.get_internet_interface()} address 00:B0:D0:63:C2:26') + util.run_command(f'ip addr flush dev {self._session.get_internet_interface()}') + util.run_command(f'ip addr add dev {self._session.get_internet_interface()} 0.0.0.0') util.run_command( f'ip addr add dev {INTERNET_BRIDGE} {self._ipv4} broadcast {self._brd}') util.run_command(f'ip -6 addr add {self._ipv6} dev {INTERNET_BRIDGE} ') @@ -316,17 +293,11 @@ def _ci_post_network_create(self): def create_net(self): LOGGER.info('Creating baseline network') - if not util.interface_exists(self._int_intf) or not util.interface_exists( - self._dev_intf): - LOGGER.error('Configured interfaces are not ready for use. ' + - 'Ensure both interfaces are connected.') - sys.exit(1) - if self._single_intf: self._ci_pre_network_create() # Remove IP from internet adapter - util.run_command('ifconfig ' + self._int_intf + ' 0.0.0.0') + util.run_command('ifconfig ' + self._session.get_internet_interface() + ' 0.0.0.0') # Setup the virtual network if not self._ovs.create_baseline_net(verify=True): @@ -339,10 +310,10 @@ def create_net(self): self._create_private_net() - self.listener = Listener(self._dev_intf) - self.listener.register_callback(self._device_discovered, + self._listener = Listener(self._session) + self.get_listener().register_callback(self._device_discovered, [NetworkEvent.DEVICE_DISCOVERED]) - self.listener.register_callback(self._dhcp_lease_ack, + self.get_listener().register_callback(self._dhcp_lease_ack, [NetworkEvent.DHCP_LEASE_ACK]) def load_network_modules(self): @@ -661,9 +632,8 @@ def restore_net(self): LOGGER.info('Clearing baseline network') - if hasattr(self, 'listener' - ) and self.listener is not None and self.listener.is_running(): - self.listener.stop_listener() + if hasattr(self, 'listener') and self.get_listener() is not None and self.get_listener().is_running(): + self.get_listener().stop_listener() client = docker.from_env() @@ -681,10 +651,12 @@ def restore_net(self): # Clean up any existing network artifacts self._ip_ctrl.clean_all() + internet_intf = self._session.get_internet_interface() + # Restart internet interface - if util.interface_exists(self._int_intf): - util.run_command('ip link set ' + self._int_intf + ' down') - util.run_command('ip link set ' + self._int_intf + ' up') + if util.interface_exists(internet_intf): + util.run_command('ip link set ' + internet_intf + ' down') + util.run_command('ip link set ' + internet_intf + ' up') LOGGER.info('Network is restored') @@ -712,10 +684,6 @@ def __init__(self): self.net_config = NetworkModuleNetConfig() - -# The networking configuration for a network module - - class NetworkModuleNetConfig: """Define all the properties of the network config for a network module""" @@ -738,10 +706,6 @@ def get_ipv4_addr_with_prefix(self): def get_ipv6_addr_with_prefix(self): return format(self.ipv6_address) + '/' + str(self.ipv6_network.prefixlen) - -# Represents the current configuration of the network for the device bridge - - class NetworkConfig: """Define all the properties of the network configuration""" diff --git a/framework/python/src/net_orc/ovs_control.py b/framework/python/src/net_orc/ovs_control.py index 83823e8fa..c48e58e3b 100644 --- a/framework/python/src/net_orc/ovs_control.py +++ b/framework/python/src/net_orc/ovs_control.py @@ -18,7 +18,6 @@ from common import logger from common import util -CONFIG_FILE = 'local/system.json' DEVICE_BRIDGE = 'tr-d' INTERNET_BRIDGE = 'tr-c' LOGGER = logger.get_logger('ovs_ctrl') @@ -27,10 +26,8 @@ class OVSControl: """OVS Control""" - def __init__(self): - self._int_intf = None - self._dev_intf = None - self._load_config() + def __init__(self, session): + self._session = session def add_bridge(self, bridge_name): LOGGER.debug('Adding OVS bridge: ' + bridge_name) @@ -80,11 +77,11 @@ def validate_baseline_network(self): LOGGER.debug('Validating baseline network') # Verify the device bridge - dev_bridge = self.verify_bridge(DEVICE_BRIDGE, [self._dev_intf]) + dev_bridge = self.verify_bridge(DEVICE_BRIDGE, [self._session.get_device_interface()]) LOGGER.debug('Device bridge verified: ' + str(dev_bridge)) # Verify the internet bridge - int_bridge = self.verify_bridge(INTERNET_BRIDGE, [self._int_intf]) + int_bridge = self.verify_bridge(INTERNET_BRIDGE, [self._session.get_internet_interface()]) LOGGER.debug('Internet bridge verified: ' + str(int_bridge)) return dev_bridge and int_bridge @@ -107,7 +104,7 @@ def create_baseline_net(self, verify=True): LOGGER.debug('Creating baseline network') # Remove IP from internet adapter - self.set_interface_ip(interface=self._int_intf, ip_addr='0.0.0.0') + self.set_interface_ip(interface=self._session.get_internet_interface(), ip_addr='0.0.0.0') # Create data plane self.add_bridge(DEVICE_BRIDGE) @@ -116,11 +113,11 @@ def create_baseline_net(self, verify=True): self.add_bridge(INTERNET_BRIDGE) # Remove IP from internet adapter - self.set_interface_ip(self._int_intf, '0.0.0.0') + self.set_interface_ip(self._session.get_internet_interface(), '0.0.0.0') # Add external interfaces to data and control plane - self.add_port(self._dev_intf, DEVICE_BRIDGE) - self.add_port(self._int_intf, INTERNET_BRIDGE) + self.add_port(self._session.get_device_interface(), DEVICE_BRIDGE) + self.add_port(self._session.get_internet_interface(), INTERNET_BRIDGE) # Enable forwarding of eapol packets self.add_flow(bridge_name=DEVICE_BRIDGE, @@ -145,20 +142,6 @@ def delete_bridge(self, bridge_name): success = util.run_command('ovs-vsctl --if-exists del-br ' + bridge_name) return success - def _load_config(self): - path = os.path.dirname(os.path.dirname( - os.path.dirname( - os.path.dirname(os.path.dirname(os.path.realpath(__file__)))))) - config_file = os.path.join(path, CONFIG_FILE) - LOGGER.debug('Loading configuration: ' + config_file) - with open(config_file, 'r', encoding='utf-8') as conf_file: - config_json = json.load(conf_file) - self._int_intf = config_json['network']['internet_intf'] - self._dev_intf = config_json['network']['device_intf'] - LOGGER.debug('Configuration loaded') - LOGGER.debug('Internet interface: ' + self._int_intf) - LOGGER.debug('Device interface: ' + self._dev_intf) - def restore_net(self): LOGGER.debug('Restoring network...') # Delete data plane diff --git a/framework/python/src/test_orc/test_orchestrator.py b/framework/python/src/test_orc/test_orchestrator.py index fef4e5bb5..74e399df1 100644 --- a/framework/python/src/test_orc/test_orchestrator.py +++ b/framework/python/src/test_orc/test_orchestrator.py @@ -32,9 +32,10 @@ class TestOrchestrator: """Manages and controls the test modules.""" - def __init__(self, net_orc): + def __init__(self, session, net_orc): self._test_modules = [] self._module_config = None + self._session = session self._net_orc = net_orc self._test_in_progress = False @@ -45,7 +46,6 @@ def __init__(self, net_orc): # Resolve the path to the test-run folder #self._root_path = os.path.abspath(os.path.join(self._path, os.pardir)) - self._root_path = os.path.dirname(os.path.dirname( os.path.dirname( os.path.dirname(os.path.dirname(os.path.realpath(__file__)))))) @@ -117,7 +117,7 @@ def test_in_progress(self): def _is_module_enabled(self, module, device): enabled = True if device.test_modules is not None: - test_modules = json.loads(device.test_modules) + test_modules = device.test_modules if module.name in test_modules: if "enabled" in test_modules[module.name]: enabled = test_modules[module.name]["enabled"] @@ -182,7 +182,7 @@ def _run_test_module(self, module, device): environment={ "HOST_USER": self._host_user, "DEVICE_MAC": device.mac_addr, - "DEVICE_TEST_MODULES": device.test_modules, + "DEVICE_TEST_MODULES": json.dumps(device.test_modules), "IPV4_SUBNET": self._net_orc.network_config.ipv4_network, "IPV6_SUBNET": self._net_orc.network_config.ipv6_network }) @@ -201,8 +201,15 @@ def _run_test_module(self, module, device): test_module_timeout = time.time() + module.timeout status = self._get_module_status(module) - while time.time() < test_module_timeout and status == "running": - time.sleep(1) + log_stream = module.container.logs(stream=True, stdout=True, stderr=True) + while (time.time() < test_module_timeout and + status == "running" and + self._session.get_status() == "In progress"): + try: + line = next(log_stream).decode("utf-8").strip() + print(line) + except Exception: + time.sleep(1) status = self._get_module_status(module) LOGGER.info("Test module " + module.name + " has finished") diff --git a/framework/requirements.txt b/framework/requirements.txt index 03eab9796..560c2baf9 100644 --- a/framework/requirements.txt +++ b/framework/requirements.txt @@ -5,4 +5,10 @@ requests<2.29.0 docker ipaddress netifaces -scapy \ No newline at end of file +scapy + +# Requirements for the API +fastapi==0.99.1 +psutil +uvicorn +pydantic==1.10.11 \ No newline at end of file diff --git a/modules/test/base/base.Dockerfile b/modules/test/base/base.Dockerfile index 10344cbc7..62ff54d6c 100644 --- a/modules/test/base/base.Dockerfile +++ b/modules/test/base/base.Dockerfile @@ -45,4 +45,4 @@ COPY $NET_MODULE_DIR/dhcp-1/$NET_MODULE_PROTO_DIR $CONTAINER_PROTO_DIR/dhcp1/ COPY $NET_MODULE_DIR/dhcp-2/$NET_MODULE_PROTO_DIR $CONTAINER_PROTO_DIR/dhcp2/ # Start the test module -ENTRYPOINT [ "/testrun/bin/start_module" ] \ No newline at end of file +ENTRYPOINT [ "/testrun/bin/start" ] \ No newline at end of file diff --git a/modules/test/base/bin/capture b/modules/test/base/bin/capture index 69fa916c3..e237f3d72 100644 --- a/modules/test/base/bin/capture +++ b/modules/test/base/bin/capture @@ -27,7 +27,7 @@ INTERFACE=$2 # Create the output directory and start the capture mkdir -p $PCAP_DIR chown $HOST_USER $PCAP_DIR -tcpdump -i $INTERFACE -w $PCAP_DIR/$PCAP_FILE -Z $HOST_USER & +tcpdump -i $INTERFACE -w $PCAP_DIR/$PCAP_FILE -Z $HOST_USER & 2>&1 /dev/null # Small pause to let the capture to start sleep 1 \ No newline at end of file diff --git a/modules/test/base/bin/setup_binaries b/modules/test/base/bin/setup_binaries index 6af744693..eaccf9de6 100644 --- a/modules/test/base/bin/setup_binaries +++ b/modules/test/base/bin/setup_binaries @@ -18,7 +18,7 @@ BIN_DIR=$1 # Remove incorrect line endings -dos2unix $BIN_DIR/* +dos2unix $BIN_DIR/* >/dev/null 2>&1 # Make sure all the bin files are executable chmod u+x $BIN_DIR/* \ No newline at end of file diff --git a/framework/python/src/net_orc/network_device.py b/modules/test/base/bin/start old mode 100644 new mode 100755 similarity index 71% rename from framework/python/src/net_orc/network_device.py rename to modules/test/base/bin/start index f17ac0f0d..6869d1116 --- a/framework/python/src/net_orc/network_device.py +++ b/modules/test/base/bin/start @@ -1,24 +1,17 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Track device object information.""" -from dataclasses import dataclass - - -@dataclass -class NetworkDevice: - """Represents a physical device and it's configuration.""" - - mac_addr: str - ip_addr: str = None +#!/bin/bash + +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +/testrun/bin/start_module > /dev/null \ No newline at end of file diff --git a/modules/test/base/bin/start_module b/modules/test/base/bin/start_module index 82c9d26bf..69f399feb 100644 --- a/modules/test/base/bin/start_module +++ b/modules/test/base/bin/start_module @@ -99,4 +99,4 @@ fi sleep 3 # Start the networking service -$BIN_DIR/start_test_module $MODULE_NAME $IFACE \ No newline at end of file +$BIN_DIR/start_test_module $MODULE_NAME $IFACE > /runtime/output/container.log \ No newline at end of file diff --git a/modules/test/base/python/src/test_module.py b/modules/test/base/python/src/test_module.py index e949976fa..6ff4f815b 100644 --- a/modules/test/base/python/src/test_module.py +++ b/modules/test/base/python/src/test_module.py @@ -61,8 +61,6 @@ def _get_device_tests(self, device_test_module): if 'tests' in device_test_module: if test['name'] in device_test_module['tests']: dev_test_config = device_test_module['tests'][test['name']] - if 'enabled' in dev_test_config: - test['enabled'] = dev_test_config['enabled'] if 'config' in test and 'config' in dev_test_config: test['config'].update(dev_test_config['config']) return module_tests @@ -83,19 +81,17 @@ def run_tests(self): test_method_name = '_' + test['name'].replace('.', '_') result = None test['start'] = datetime.now().isoformat() - if ('enabled' in test and test['enabled']) or 'enabled' not in test: - LOGGER.info('Attempting to run test: ' + test['name']) - # Resolve the correct python method by test name and run test - if hasattr(self, test_method_name): - if 'config' in test: - result = getattr(self, test_method_name)(config=test['config']) - else: - result = getattr(self, test_method_name)() + LOGGER.info('Attempting to run test: ' + test['name']) + # Resolve the correct python method by test name and run test + if hasattr(self, test_method_name): + if 'config' in test: + result = getattr(self, test_method_name)(config=test['config']) else: - LOGGER.info(f'Test {test["name"]} not resolved. Skipping') - result = None + result = getattr(self, test_method_name)() else: - LOGGER.info(f'Test {test["name"]} disabled. Skipping') + LOGGER.info(f'Test {test["name"]} not resolved. Skipping') + result = None + if result is not None: if isinstance(result, bool): test['result'] = 'compliant' if result else 'non-compliant' diff --git a/modules/test/conn/bin/start_test_module b/modules/test/conn/bin/start_test_module index 0df510b86..d85ae7d6b 100644 --- a/modules/test/conn/bin/start_test_module +++ b/modules/test/conn/bin/start_test_module @@ -45,7 +45,7 @@ touch $RESULT_FILE chown $HOST_USER $LOG_FILE chown $HOST_USER $RESULT_FILE -# Run the python scrip that will execute the tests for this module +# Run the python script that will execute the tests for this module # -u flag allows python print statements # to be logged by docker by running unbuffered python3 -u $PYTHON_SRC_DIR/run.py "-m $MODULE_NAME" diff --git a/modules/test/conn/python/src/connection_module.py b/modules/test/conn/python/src/connection_module.py index 419fba68a..d432d2131 100644 --- a/modules/test/conn/python/src/connection_module.py +++ b/modules/test/conn/python/src/connection_module.py @@ -27,7 +27,6 @@ STARTUP_CAPTURE_FILE = '/runtime/device/startup.pcap' MONITOR_CAPTURE_FILE = '/runtime/device/monitor.pcap' SLAAC_PREFIX = 'fd10:77be:4186' - TR_CONTAINER_MAC_PREFIX = '9a:02:57:1e:8f:' @@ -132,10 +131,10 @@ def _connection_single_ip(self): LOGGER.info('Inspecting: ' + str(len(packets)) + ' packets') for packet in packets: # Option[1] = message-type, option 3 = DHCPREQUEST - if DHCP in packet and packet[DHCP].options[0][1] == 3: - mac_address = packet[Ether].src - if not mac_address.startswith(TR_CONTAINER_MAC_PREFIX): - mac_addresses.add(mac_address.upper()) + if DHCP in packet and packet[DHCP].options[0][1] == 3: + mac_address = packet[Ether].src + if not mac_address.startswith(TR_CONTAINER_MAC_PREFIX): + mac_addresses.add(mac_address.upper()) # Check if the device mac address is in the list of DHCPREQUESTs result = self._device_mac.upper() in mac_addresses @@ -349,7 +348,6 @@ def _run_subnet_test(self,config): return final_result, final_result_details - def _test_subnet(self, subnet, lease): if self._change_subnet(subnet): expiration = datetime.strptime(lease['expires'], '%Y-%m-%d %H:%M:%S') diff --git a/modules/test/dns/python/src/dns_module.py b/modules/test/dns/python/src/dns_module.py index 8d32d4dfb..aecbd5bd1 100644 --- a/modules/test/dns/python/src/dns_module.py +++ b/modules/test/dns/python/src/dns_module.py @@ -55,8 +55,7 @@ def _dns_network_from_dhcp(self): self._dns_server) # Check if the device DNS traffic is to appropriate server - tcpdump_filter = (f'dst port 53 and dst host {self._dns_server}', - f' and ether src {self._device_mac}') + tcpdump_filter = f'dst port 53 and dst host {self._dns_server} and ether src {self._device_mac}' result = self._check_dns_traffic(tcpdump_filter=tcpdump_filter) diff --git a/modules/ui/conf/nginx.conf b/modules/ui/conf/nginx.conf new file mode 100644 index 000000000..ade6ad17a --- /dev/null +++ b/modules/ui/conf/nginx.conf @@ -0,0 +1,13 @@ +events{} +http { + include /etc/nginx/mime.types; + server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + location / { + try_files $uri $uri/ /index.html; + } + } +} \ No newline at end of file diff --git a/modules/ui/ui.Dockerfile b/modules/ui/ui.Dockerfile new file mode 100644 index 000000000..f65f4c48b --- /dev/null +++ b/modules/ui/ui.Dockerfile @@ -0,0 +1,19 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Image name: test-run/ui +FROM nginx:1.25.1 + +COPY modules/ui/conf/nginx.conf /etc/nginx/nginx.conf +COPY ui /usr/share/nginx/html \ No newline at end of file diff --git a/resources/devices/template/device_config.json b/resources/devices/template/device_config.json index 1e92de25d..7ee63cf95 100644 --- a/resources/devices/template/device_config.json +++ b/resources/devices/template/device_config.json @@ -4,176 +4,19 @@ "mac_addr": "aa:bb:cc:dd:ee:ff", "test_modules": { "dns": { - "enabled": true, - "tests": { - "dns.network.from_device": { - "enabled": true - }, - "dns.network.from_dhcp": { - "enabled": true - } - } + "enabled": true }, "connection": { - "enabled": true, - "tests": { - "connection.mac_address": { - "enabled": true - }, - "connection.mac_oui": { - "enabled": true - }, - "connection.target_ping": { - "enabled": true - } - , - "connection.single_ip": { - "enabled": true - } - } + "enabled": true }, "ntp": { - "enabled": true, - "tests": { - "ntp.network.ntp_support": { - "enabled": true - }, - "ntp.network.ntp_dhcp": { - "enabled": true - } - } + "enabled": true }, "baseline": { - "enabled": false, - "tests": { - "baseline.non-compliant": { - "enabled": true - }, - "baseline.pass": { - "enabled": true - }, - "baseline.skip": { - "enabled": true - } - } + "enabled": false }, "nmap": { - "enabled": true, - "tests": { - "security.nmap.ports": { - "enabled": true, - "security.services.ftp": { - "tcp_ports": { - "20": { - "allowed": false - }, - "21": { - "allowed": false - } - } - }, - "security.services.ssh": { - "tcp_ports": { - "22": { - "allowed": true - } - } - }, - "security.services.telnet": { - "tcp_ports": { - "23": { - "allowed": false - } - } - }, - "security.services.smtp": { - "tcp_ports": { - "25": { - "allowed": false - }, - "465": { - "allowed": false - }, - "587": { - "allowed": false - } - } - }, - "security.services.http": { - "tcp_ports": { - "80": { - "allowed": false - }, - "443": { - "allowed": true - } - } - }, - "security.services.pop": { - "tcp_ports": { - "110": { - "allowed": false - } - } - }, - "security.services.imap": { - "tcp_ports": { - "143": { - "allowed": false - } - } - }, - "security.services.snmpv3": { - "tcp_ports": { - "161": { - "allowed": false - }, - "162": { - "allowed": false - } - }, - "udp_ports": { - "161": { - "allowed": false - }, - "162": { - "allowed": false - } - } - }, - "security.services.https": { - "tcp_ports": { - "80": { - "allowed": false - } - } - }, - "security.services.vnc": { - "tcp_ports": { - "5500": { - "allowed": false - }, - "5800": { - "allowed": false - } - } - }, - "security.services.tftp": { - "udp_ports": { - "69": { - "allowed": false - } - } - }, - "security.services.ntp": { - "udp_ports": { - "123": { - "allowed": false - } - } - } - } - } + "enabled": true } } } diff --git a/testing/test_baseline b/testing/baseline/test_baseline similarity index 95% rename from testing/test_baseline rename to testing/baseline/test_baseline index 2b95ded23..61d0f9b56 100755 --- a/testing/test_baseline +++ b/testing/baseline/test_baseline @@ -48,7 +48,7 @@ EOF sudo cmd/install -sudo cmd/start --single-intf > $TESTRUN_OUT 2>&1 & +sudo bin/testrun --single-intf --no-ui > $TESTRUN_OUT 2>&1 & TPID=$! # Time to wait for testrun to be ready @@ -80,6 +80,6 @@ echo "Done baseline test" more $TESTRUN_OUT -pytest testing/test_baseline.py +pytest testing/baseline/test_baseline.py exit $? \ No newline at end of file diff --git a/testing/test_baseline.py b/testing/baseline/test_baseline.py similarity index 100% rename from testing/test_baseline.py rename to testing/baseline/test_baseline.py diff --git a/testing/device_configs/tester1/device_config.json b/testing/device_configs/tester1/device_config.json new file mode 100644 index 000000000..268399b72 --- /dev/null +++ b/testing/device_configs/tester1/device_config.json @@ -0,0 +1,22 @@ +{ + "manufacturer": "Google", + "model": "Tester 1", + "mac_addr": "02:42:aa:00:00:01", + "test_modules": { + "dns": { + "enabled": false + }, + "connection": { + "enabled": false + }, + "ntp": { + "enabled": false + }, + "baseline": { + "enabled": false + }, + "nmap": { + "enabled": true + } + } +} diff --git a/testing/device_configs/tester2/device_config.json b/testing/device_configs/tester2/device_config.json new file mode 100644 index 000000000..8b090d80a --- /dev/null +++ b/testing/device_configs/tester2/device_config.json @@ -0,0 +1,22 @@ +{ + "manufacturer": "Google", + "model": "Tester 2", + "mac_addr": "02:42:aa:00:00:02", + "test_modules": { + "dns": { + "enabled": false + }, + "connection": { + "enabled": false + }, + "ntp": { + "enabled": true + }, + "baseline": { + "enabled": false + }, + "nmap": { + "enabled": true + } + } +} diff --git a/testing/docker/ci_test_device1/Dockerfile b/testing/docker/ci_test_device1/Dockerfile index 4328946fd..93c0e7131 100644 --- a/testing/docker/ci_test_device1/Dockerfile +++ b/testing/docker/ci_test_device1/Dockerfile @@ -1,10 +1,12 @@ FROM ubuntu:jammy -ENV DEBIAN_FRONTEND=noninteractive -#Update and get all additional requirements not contained in the base image + +ENV DEBIAN_FRONTEND=noninteractive + +#Update and get all additional requirements not contained in the base image RUN apt-get update && apt-get -y upgrade -RUN apt-get update && apt-get install -y isc-dhcp-client ntpdate coreutils moreutils inetutils-ping curl jq dnsutils openssl netcat-openbsd +RUN apt-get update && apt-get install -y isc-dhcp-client ntpdate coreutils moreutils inetutils-ping curl jq dnsutils openssl netcat-openbsd COPY entrypoint.sh /entrypoint.sh diff --git a/testing/test_pylint b/testing/pylint/test_pylint similarity index 100% rename from testing/test_pylint rename to testing/pylint/test_pylint diff --git a/testing/example/mac b/testing/tests/example/mac similarity index 100% rename from testing/example/mac rename to testing/tests/example/mac diff --git a/testing/example/mac1/results.json b/testing/tests/example/mac1/results.json similarity index 100% rename from testing/example/mac1/results.json rename to testing/tests/example/mac1/results.json diff --git a/testing/test_tests b/testing/tests/test_tests similarity index 93% rename from testing/test_tests rename to testing/tests/test_tests index ed14f1043..be7a3cef3 100755 --- a/testing/test_tests +++ b/testing/tests/test_tests @@ -17,7 +17,7 @@ set -o xtrace ip a TEST_DIR=/tmp/results -MATRIX=testing/test_tests.json +MATRIX=testing/tests/test_tests.json mkdir -p $TEST_DIR @@ -50,6 +50,9 @@ cat <local/system.json } EOF +mkdir -p local/devices +cp -r testing/device_configs/* local/devices + sudo cmd/install TESTERS=$(jq -r 'keys[]' $MATRIX) @@ -62,7 +65,7 @@ for tester in $TESTERS; do args=$(jq -r .$tester.args $MATRIX) touch $testrun_log - sudo timeout 900 cmd/start --single-intf > $testrun_log 2>&1 & + sudo timeout 900 bin/testrun --single-intf --no-ui > $testrun_log 2>&1 & TPID=$! # Time to wait for testrun to be ready @@ -115,6 +118,6 @@ for tester in $TESTERS; do done -pytest -v testing/test_tests.py +pytest -v testing/tests/test_tests.py exit $? diff --git a/testing/test_tests.json b/testing/tests/test_tests.json similarity index 100% rename from testing/test_tests.json rename to testing/tests/test_tests.json diff --git a/testing/test_tests.py b/testing/tests/test_tests.py similarity index 92% rename from testing/test_tests.py rename to testing/tests/test_tests.py index b61fdf064..1f484647a 100644 --- a/testing/test_tests.py +++ b/testing/tests/test_tests.py @@ -29,7 +29,7 @@ TEST_MATRIX = 'test_tests.json' RESULTS_PATH = '/tmp/results/*.json' -#TODO add reason +#TODO add reason @dataclass(frozen=True) class TestResult: name: str @@ -80,14 +80,14 @@ def test_list_tests(capsys, results, test_matrix): all_tests = set(itertools.chain.from_iterable( [collect_actual_results(results[x]) for x in results.keys()])) - ci_pass = set([test - for testers in test_matrix.values() - for test, result in testers['expected_results'].items() + ci_pass = set([test + for testers in test_matrix.values() + for test, result in testers['expected_results'].items() if result == 'compliant']) - ci_fail = set([test - for testers in test_matrix.values() - for test, result in testers['expected_results'].items() + ci_fail = set([test + for testers in test_matrix.values() + for test, result in testers['expected_results'].items() if result == 'non-compliant']) with capsys.disabled(): diff --git a/testing/unit_test/run_tests.sh b/testing/unit/run_tests.sh similarity index 100% rename from testing/unit_test/run_tests.sh rename to testing/unit/run_tests.sh diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 000000000..285fce5ad --- /dev/null +++ b/ui/index.html @@ -0,0 +1 @@ +Test Run \ No newline at end of file