diff --git a/framework/python/src/api/api.py b/framework/python/src/api/api.py index 29a1f26fb..1efd3c818 100644 --- a/framework/python/src/api/api.py +++ b/framework/python/src/api/api.py @@ -39,6 +39,10 @@ DEVICES_PATH = "local/devices" DEFAULT_DEVICE_INTF = "enx123456789123" +RESOURCES_PATH = "resources" +DEVICE_FOLDER_PATH = "devices" +DEVICE_QUESTIONS_FILE_NAME = "device_profile.json" + LATEST_RELEASE_CHECK = ("https://api.github.com/repos/google/" + "testrun/releases/latest") @@ -46,32 +50,44 @@ class Api: """Provide REST endpoints to manage Testrun""" - def __init__(self, test_run): + def __init__(self, testrun): - self._test_run = test_run + self._testrun = testrun self._name = "Testrun API" self._router = APIRouter() - self._session = self._test_run.get_session() + # Load static JSON resources + device_resources = os.path.join(self._testrun.get_root_dir(), + RESOURCES_PATH, + DEVICE_FOLDER_PATH) + + # Load device profile questions + self._device_profile = self._load_json(device_resources, + DEVICE_QUESTIONS_FILE_NAME) + + # Fetch Testrun session + self._session = self._testrun.get_session() + # System endpoints 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, + self.start_testrun, methods=["POST"]) self._router.add_api_route("/system/stop", - self.stop_test_run, + self.stop_testrun, methods=["POST"]) self._router.add_api_route("/system/status", self.get_status) self._router.add_api_route("/system/shutdown", self.shutdown, methods=["POST"]) - self._router.add_api_route("/system/version", self.get_version) + self._router.add_api_route("/system/modules", self.get_test_modules) + # Report endpoints self._router.add_api_route("/reports", self.get_reports) self._router.add_api_route("/report", self.delete_report, @@ -82,6 +98,7 @@ def __init__(self, test_run): self.get_results, methods=["POST"]) + # Device endpoints self._router.add_api_route("/devices", self.get_devices) self._router.add_api_route("/device", self.delete_device, @@ -90,10 +107,9 @@ def __init__(self, test_run): self._router.add_api_route("/device/edit", self.edit_device, methods=["POST"]) + self._router.add_api_route("/devices/format", self.get_devices_profile) - # Load modules - self._router.add_api_route("/system/modules", self.get_test_modules) - + # Certificate endpoints self._router.add_api_route("/system/config/certs", self.get_certs) self._router.add_api_route("/system/config/certs", self.upload_cert, @@ -116,10 +132,15 @@ def __init__(self, test_run): origins = ["*"] # Scheduler for background periodic tasks - self._scheduler = tasks.PeriodicTasks(self._test_run) + self._scheduler = tasks.PeriodicTasks(self._testrun) + # Init FastAPI self._app = FastAPI(lifespan=self._scheduler.start) + + # Attach router to FastAPI self._app.include_router(self._router) + + # Attach CORS middleware self._app.add_middleware( CORSMiddleware, allow_origins=origins, @@ -128,10 +149,27 @@ def __init__(self, test_run): allow_headers=["*"], ) + # Use separate thread for API self._api_thread = threading.Thread(target=self._start, name="Testrun API", daemon=True) + def _load_json(self, directory, file_name): + """Utility method to load json files' """ + # Construct the base path relative to the main folder + root_dir = self._testrun.get_root_dir() + + # Construct the full file path + file_path = os.path.join(root_dir, directory, file_name) + + # Open the file in read mode + with open(file_path, "r", encoding="utf-8") as file: + # Return the file content + return json.load(file) + + def _get_testrun(self): + return self._testrun + def start(self): LOGGER.info("Starting API") self._api_thread.start() @@ -197,7 +235,7 @@ async def get_devices(self): devices.append(device.to_dict()) return devices - async def start_test_run(self, request: Request, response: Response): + async def start_testrun(self, request: Request, response: Response): LOGGER.debug("Received start command") @@ -219,7 +257,7 @@ async def start_test_run(self, request: Request, response: Response): device = self._session.get_device(body_json["device"]["mac_addr"]) # Check Testrun is not already running - if self._test_run.get_session().get_status() in [ + if self._testrun.get_session().get_status() in [ TestrunStatus.IN_PROGRESS, TestrunStatus.WAITING_FOR_DEVICE, TestrunStatus.MONITORING @@ -239,14 +277,14 @@ async def start_test_run(self, request: Request, response: Response): device.firmware = body_json["device"]["firmware"] # Check if config has been updated (device interface not default) - if (self._test_run.get_session().get_device_interface() == + if (self._testrun.get_session().get_device_interface() == DEFAULT_DEVICE_INTF): response.status_code = status.HTTP_400_BAD_REQUEST return self._generate_msg( False, "Testrun configuration has not yet " + "been completed.") # Check Testrun is able to start - if self._test_run.get_net_orc().check_config() is False: + if self._testrun.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 " + @@ -266,12 +304,12 @@ async def start_test_run(self, request: Request, response: Response): f"{device.manufacturer} {device.model} with " + f"MAC address {device.mac_addr}") - thread = threading.Thread(target=self._start_test_run, name="Testrun") + thread = threading.Thread(target=self._start_testrun, name="Testrun") thread.start() - self._test_run.get_session().set_target_device(device) + self._testrun.get_session().set_target_device(device) - return self._test_run.get_session().to_json() + return self._testrun.get_session().to_json() def _generate_msg(self, success, message): msg_type = "success" @@ -279,10 +317,10 @@ def _generate_msg(self, success, message): msg_type = "error" return json.loads('{"' + msg_type + '": "' + message + '"}') - def _start_test_run(self): - self._test_run.start() + def _start_testrun(self): + self._testrun.start() - async def stop_test_run(self, response: Response): + async def stop_testrun(self, response: Response): LOGGER.debug("Received stop command") # Check if Testrun is running @@ -293,12 +331,12 @@ async def stop_test_run(self, response: Response): response.status_code = 404 return self._generate_msg(False, "Testrun is not currently running") - self._test_run.stop() + self._testrun.stop() return self._generate_msg(True, "Testrun stopped") async def get_status(self): - return self._test_run.get_session().to_json() + return self._testrun.get_session().to_json() def shutdown(self, response: Response): @@ -316,14 +354,14 @@ def shutdown(self, response: Response): return self._generate_msg( False, "Unable to shutdown. A test is currently in progress.") - self._test_run.shutdown() + self._testrun.shutdown() os.kill(os.getpid(), signal.SIGTERM) async def get_version(self, response: Response): # Add defaults json_response = {} - json_response["installed_version"] = "v" + self._test_run.get_version() + json_response["installed_version"] = "v" + self._testrun.get_version() json_response["update_available"] = False json_response["latest_version"] = None json_response["latest_version_url"] = ( @@ -421,7 +459,7 @@ async def delete_report(self, request: Request, response: Response): response.status_code = 404 return self._generate_msg(False, "Could not find device") - if self._test_run.delete_report(device, timestamp_formatted): + if self._testrun.delete_report(device, timestamp_formatted): return self._generate_msg(True, "Deleted report") response.status_code = 500 @@ -445,7 +483,7 @@ async def delete_device(self, request: Request, response: Response): mac_addr = device_json.get("mac_addr").lower() # Check that device exists - device = self._test_run.get_session().get_device(mac_addr) + device = self._testrun.get_session().get_device(mac_addr) if device is None: response.status_code = 404 @@ -463,7 +501,7 @@ async def delete_device(self, request: Request, response: Response): False, "Cannot delete this device whilst " + "it is being tested") # Delete device - self._test_run.delete_device(device) + self._testrun.delete_device(device) # Return success response response.status_code = 200 @@ -525,7 +563,7 @@ async def save_device(self, request: Request, response: Response): device.device_folder = device.manufacturer + " " + device.model device.test_modules = device_json.get(DEVICE_TEST_MODULES_KEY) - self._test_run.create_device(device) + self._testrun.create_device(device) response.status_code = status.HTTP_201_CREATED else: @@ -596,7 +634,7 @@ async def edit_device(self, request: Request, response: Response): device.model = device_json.get(DEVICE_MODEL_KEY) device.test_modules = device_json.get(DEVICE_TEST_MODULES_KEY) - self._test_run.save_device(device, device_json) + self._testrun.save_device(device, device_json) response.status_code = status.HTTP_200_OK return device.to_config_json() @@ -662,7 +700,7 @@ async def get_results(self, request: Request, response: Response, device_name, return self._generate_msg(False, "A device with that name could not be found") - file_path = self._get_test_run().get_test_orc().zip_results( + file_path = self._get_testrun().get_test_orc().zip_results( device, timestamp, profile) if file_path is None: @@ -677,6 +715,25 @@ async def get_results(self, request: Request, response: Response, device_name, response.status_code = 404 return self._generate_msg(False, "Test results could not be found") + async def get_devices_profile(self, + request: Request, + response: Response, + step: int = 1): + """Device profile questions""" + + all_steps = len(self._device_profile) + + try: + questions = self._device_profile[step-1] + if step < all_steps: + questions["next_step"] = f"{request.url.path}?step={step + 1}" + return questions + + except IndexError: + response.status_code = status.HTTP_404_NOT_FOUND + return self._generate_msg( + False, f"Step {step} does not exist.") + def _validate_device_json(self, json_obj): # Check all required properties are present @@ -700,9 +757,6 @@ def _validate_device_json(self, json_obj): return True - def _get_test_run(self): - return self._test_run - # Profiles def get_profiles_format(self, response: Response): @@ -911,7 +965,7 @@ async def delete_cert(self, request: Request, response: Response): def get_test_modules(self): modules = [] - for module in self._test_run.get_test_orc().get_test_modules(): + for module in self._testrun.get_test_orc().get_test_modules(): if module.enabled and module.enable_container: modules.append(module.display_name) return modules diff --git a/framework/python/src/core/testrun.py b/framework/python/src/core/testrun.py index 061710c06..8fe325774 100644 --- a/framework/python/src/core/testrun.py +++ b/framework/python/src/core/testrun.py @@ -12,13 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""The overall control of the Test Run application. - +"""The overall control of the Testrun application. This file provides the integration between all of the Testrun components, such as net_orc, test_orc and test_ui. - -Run using the provided command scripts in the cmd folder. -E.g sudo cmd/start """ import docker import json @@ -39,13 +35,6 @@ from docker.errors import ImageNotFound -# 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)))) - LOGGER = logger.get_logger('test_run') DEFAULT_CONFIG_FILE = 'local/system.json' @@ -66,7 +55,7 @@ MAX_DEVICE_REPORTS_KEY = 'max_device_reports' class Testrun: # pylint: disable=too-few-public-methods - """Test Run controller. + """Testrun controller. Creates an instance of the network orchestrator, test orchestrator and user interface. @@ -79,6 +68,15 @@ def __init__(self, single_intf=False, no_ui=False): + # 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 + self._root_dir = os.path.dirname(os.path.dirname( + os.path.dirname(os.path.dirname(current_dir)))) + + # Determine config file if config_file is None: self._config_file = self._get_config_abs(DEFAULT_CONFIG_FILE) else: @@ -86,6 +84,7 @@ def __init__(self, self._net_only = net_only self._single_intf = single_intf + # Network only option only works if UI is also # disbled so need to set no_ui if net_only is selected self._no_ui = no_ui or net_only @@ -94,7 +93,7 @@ def __init__(self, self._register_exits() # Create session - self._session = TestrunSession(root_dir=root_dir) + self._session = TestrunSession(root_dir=self._root_dir) # Register runtime parameters if single_intf: @@ -144,6 +143,9 @@ def __init__(self, while True: time.sleep(1) + def get_root_dir(self): + return self._root_dir + def get_version(self): return self.get_session().get_version() @@ -228,7 +230,7 @@ def _load_test_reports(self, device): device.clear_reports() # Locate reports folder - reports_folder = os.path.join(root_dir, + reports_folder = os.path.join(self._root_dir, LOCAL_DEVICES_DIR, device.device_folder, 'reports') @@ -278,7 +280,7 @@ def delete_report(self, device: Device, timestamp): f'at {timestamp}') # Locate reports folder - reports_folder = os.path.join(root_dir, + reports_folder = os.path.join(self._root_dir, LOCAL_DEVICES_DIR, device.device_folder, 'reports') @@ -298,7 +300,7 @@ def delete_report(self, device: Device, timestamp): def create_device(self, device: Device): # Define the device folder location - device_folder_path = os.path.join(root_dir, + device_folder_path = os.path.join(self._root_dir, LOCAL_DEVICES_DIR, device.device_folder) @@ -332,7 +334,7 @@ def save_device(self, device: Device, device_json): device.test_modules = {} # Obtain the config file path - config_file_path = os.path.join(root_dir, + config_file_path = os.path.join(self._root_dir, LOCAL_DEVICES_DIR, device.device_folder, DEVICE_CONFIG) @@ -348,7 +350,7 @@ def save_device(self, device: Device, device_json): def delete_device(self, device: Device): # Obtain the config file path - device_folder = os.path.join(root_dir, + device_folder = os.path.join(self._root_dir, LOCAL_DEVICES_DIR, device.device_folder) @@ -418,14 +420,11 @@ 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, self._config_file) + config_file = os.path.join(self._root_dir, self._config_file) # Expand the config file to absolute pathing return os.path.abspath(config_file) - def get_root_dir(self): - return root_dir - def get_config_file(self): return self._get_config_abs() diff --git a/resources/devices/device_profile.json b/resources/devices/device_profile.json new file mode 100644 index 000000000..5f7be10dd --- /dev/null +++ b/resources/devices/device_profile.json @@ -0,0 +1,420 @@ +[ + { + "step": 1, + "title": "Select device type & technology", + "description": "Before your device can go through testing, tell us more about your device and its functionality. It is important that we fully understand your device before a thorough assessment can be made.", + "questions": [ + { + "id": 1, + "question": "What type of device is this?", + "validation": { + "required": true + }, + "type": "select", + "options": [ + { + "text": "Building Automation Gateway", + "risk": "High", + "id": 1 + }, + { + "text": "IoT Gateway", + "risk": "High", + "id": 2 + }, + { + "text": "Controller - AHU", + "risk": "High", + "id": 3 + }, + { + "text": "Controller - Boiler", + "risk": "High", + "id": 4 + }, + { + "text": "Controller - Chiller", + "risk": "High", + "id": 5 + }, + { + "text": "Controller - FCU", + "risk": "Limited", + "id": 6 + }, + { + "text": "Controller - Pump", + "risk": "Limited", + "id": 7 + }, + { + "text": "Controller - CRAC", + "risk": "High", + "id": 8 + }, + { + "text": "Controller - VAV", + "risk": "Limited", + "id": 9 + }, + { + "text": "Controller - VRF", + "risk": "Limited", + "id": 10 + }, + { + "text": "Controller - Multiple", + "risk": "High", + "id": 11 + }, + { + "text": "Controller - Other", + "risk": "High", + "id": 12 + }, + { + "text": "Controller - Lighting", + "risk": "Limited", + "id": 13 + }, + { + "text": "Controller - Blinds/Facades", + "risk": "High", + "id": 14 + }, + { + "text": "Controller - Lifts/Elevators", + "risk": "High", + "id": 15 + }, + { + "text": "Controller - UPS", + "risk": "High", + "id": 16 + }, + { + "text": "Sensor - Air Quality", + "risk": "Limited", + "id": 17 + }, + { + "text": "Sensor - Vibration", + "risk": "Limited", + "id": 18 + }, + { + "text": "Sensor - Humidity", + "risk": "Limited", + "id": 19 + }, + { + "text": "Sensor - Water", + "risk": "Limited", + "id": 20 + }, + { + "text": "Sensor - Occupancy", + "risk": "High", + "id": 21 + }, + { + "text": "Sensor - Volume", + "risk": "Limited", + "id": 22 + }, + { + "text": "Sensor - Weight", + "risk": "Limited", + "id": 23 + }, + { + "text": "Sensor - Weather", + "risk": "Limited", + "id": 24 + }, + { + "text": "Sensor - Steam", + "risk": "High", + "id": 25 + }, + { + "text": "Sensor - Air Flow", + "risk": "Limited", + "id": 26 + }, + { + "text": "Sensor - Lighting", + "risk": "Limited", + "id": 27 + }, + { + "text": "Sensor - Other", + "risk": "High", + "id": 28 + }, + { + "text": "Sensor - Air Quality", + "risk": "Limited", + "id": 29 + }, + { + "text": "Monitoring - Fire System", + "risk": "Limited", + "id": 30 + }, + { + "text": "Monitoring - Emergency Lighting", + "risk": "Limited", + "id": 31 + }, + { + "text": "Monitoring - Other", + "risk": "High", + "id": 32 + }, + { + "text": "Monitoring - UPS", + "risk": "Limited", + "id": 33 + }, + { + "text": "Meter - Water", + "risk": "Limited", + "id": 34 + }, + { + "text": "Meter - Gas", + "risk": "Limited", + "id": 35 + }, + { + "text": "Meter - Electricity", + "risk": "Limited", + "id": 36 + }, + { + "text": "Meter - Other", + "risk": "High", + "id": 37 + }, + { + "text": "Other", + "risk": "High", + "id": 38 + }, + { + "text": "Data - Storage", + "risk": "High", + "id": 39 + }, + { + "text": "Data - Processing", + "risk": "High", + "id": 40 + }, + { + "text": "Tablet", + "risk": "High", + "id": 41 + } + ] + }, + { + "id": 2, + "question": "Please select the technology this device falls into", + "validation": { + "required": true + }, + "type": "select", + "options": [ + { + "id": 1, + "text": "Hardware - Access Control" + }, + { + "id": 2, + "text": "Hardware - Air quality" + }, + { + "id": 3, + "text": "Hardware - Asset location tracking" + }, + { + "id": 4, + "text": "Hardware - Audio Visual" + }, + { + "id": 5, + "text": "Hardware - Blinds/Facade" + }, + { + "id": 6, + "text": "Hardware - Cameras" + }, + { + "id": 7, + "text": "Hardware - Catering" + }, + { + "id": 8, + "text": "Hardware - Data Ingestion/Managment" + }, + { + "id": 9, + "text": "Hardware - EV Charging" + }, + { + "id": 10, + "text": "Hardware - Fitness" + }, + { + "id": 11, + "text": "Hardware - HVAC" + }, + { + "id": 12, + "text": "Hardware - Irrigation" + }, + { + "id": 13, + "text": "Hardware - Leak Detection" + }, + { + "id": 14, + "text": "Hardware - Lifts/Elevators" + }, + { + "id": 15, + "text": "Hardware - Lighting" + }, + { + "id": 16, + "text": "Hardware - Metering" + }, + { + "id": 17, + "text": "Hardware - Monitoring" + }, + { + "id": 18, + "text": "Hardware - Occupancy" + }, + { + "id": 19, + "text": "Hardware - System Integration" + }, + { + "id": 20, + "text": "Hardware - Time Management" + }, + { + "id": 21, + "text": "Hardware - UPS" + }, + { + "id": 22, + "text": "Hardware - Waste Management" + }, + { + "id": 23, + "text": "Building Automation" + }, + { + "id": 24, + "text": "Other" + } + ] + }, + { + "id": 3, + "question": "Does your device process any sensitive information? ", + "validation": { + "required": true + }, + "type": "select", + "options": [ + { + "id": 1, + "text": "Yes", + "risk": "Limited" + }, + { + "id": 2, + "text": "No", + "risk": "High" + }, + { + "id": 3, + "text": "I don't know", + "risk": "High" + } + ] + } + ] + }, + { + "step": 2, + "title": "Tell us more about your device", + "description": "Before your device can go through testing, tell us more about your device and its functionality. It is important that we fully understand your device before a thorough assessment can be made.", + "questions": [ + { + "id": 1, + "question": "Can all non-essential services be disabled on your device?", + "validation": { + "required": true + }, + "type": "select", + "options": [ + { + "text": "Yes", + "id": 1 + }, + { + "text": "No", + "id": 2 + } + ] + }, + { + "id": 2, + "question": "Is there a second IP port on the device?", + "validation": { + "required": true + }, + "type": "select", + "options": [ + { + "text": "Yes", + "id": 1 + }, + { + "text": "No", + "id": 2 + } + ] + }, + { + "id": 3, + "question": "Can the second IP port on your device be disabled?", + "validation": { + "required": true + }, + "type": "select", + "options": [ + { + "text": "Yes", + "id": 1 + }, + { + "text": "No", + "id": 2 + }, + { + "text": "N/A", + "id": 3 + } + ] + } + ] + } +] \ No newline at end of file diff --git a/testing/api/test_api.py b/testing/api/test_api.py index 70c1a617f..cea760576 100644 --- a/testing/api/test_api.py +++ b/testing/api/test_api.py @@ -45,6 +45,8 @@ BASELINE_MAC_ADDR = "02:42:aa:00:01:01" ALL_MAC_ADDR = "02:42:aa:00:00:01" +DEVICE_PROFILE_QUESTIONS = "resources/devices/device_profile.json" + def pretty_print(dictionary: dict): """ Pretty print dictionary """ print(json.dumps(dictionary, indent=4))