diff --git a/.vscode/settings.json b/.vscode/settings.json index 0f284fb..1c44300 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,4 +14,13 @@ "test_*.py" ], "python.testing.unittestEnabled": true, + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.rulers": [100, 120], + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, + "editor.formatOnType": true + }, } diff --git a/Dockerfile b/Dockerfile index 16e0994..b7bd102 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ COPY /bci/web/vue ./ RUN npm run build -FROM openresty/openresty:1.25.3.1-3-bullseye AS nginx +FROM openresty/openresty:1.27.1.1-bullseye AS nginx COPY ./nginx/start.sh /usr/local/bin/ COPY ./nginx/config /etc/nginx/config COPY --from=ui-build-stage /app/dist /www/data diff --git a/bci/browser/binary/artisanal_manager.py b/bci/browser/binary/artisanal_manager.py index 904c475..de07b4e 100644 --- a/bci/browser/binary/artisanal_manager.py +++ b/bci/browser/binary/artisanal_manager.py @@ -29,7 +29,7 @@ def get_artisanal_binaries_list(self) -> list: return sorted(self.meta_info, key=lambda i: int(i["id"])) def has_artisanal_binary_for(self, state: State) -> bool: - return len(list(filter(lambda x: x['id'] == state.revision_number, self.meta_info))) > 0 + return len(list(filter(lambda x: x['id'] == state.revision_nb, self.meta_info))) > 0 def add_new_subfolders(self, subfolders): logger.info("Adding new subfolders to metadata") diff --git a/bci/browser/binary/binary.py b/bci/browser/binary/binary.py index 7b84362..f0624fe 100644 --- a/bci/browser/binary/binary.py +++ b/bci/browser/binary/binary.py @@ -3,16 +3,17 @@ import logging import os from abc import abstractmethod +from typing import Optional from bci import util from bci.browser.binary.artisanal_manager import ArtisanalBuildManager +from bci.database.mongo.binary_cache import BinaryCache from bci.version_control.states.state import State logger = logging.getLogger(__name__) class Binary: - def __init__(self, state: State): self.state = state self.__version = None @@ -40,19 +41,23 @@ def bin_folder_path(self) -> str: @property def origin(self) -> str: - if 'artisanal' in self.get_bin_path(): + bin_path = self.get_bin_path() + if bin_path is None: + raise AttributeError('Binary path is not available') + + if 'artisanal' in bin_path: return 'artisanal' - elif 'downloaded' in self.get_bin_path(): + elif 'downloaded' in bin_path: return 'downloaded' else: - raise ValueError(f'Unknown binary origin for path \'{self.get_bin_path()}\'') + raise AttributeError(f"Unknown binary origin for path '{self.get_bin_path()}'") @staticmethod def list_downloaded_binaries(bin_folder_path: str) -> list[dict[str, str]]: binaries = [] - for subfolder_path in os.listdir(os.path.join(bin_folder_path, "downloaded")): + for subfolder_path in os.listdir(os.path.join(bin_folder_path, 'downloaded')): bin_entry = {} - bin_entry["id"] = subfolder_path + bin_entry['id'] = subfolder_path binaries.append(bin_entry) return binaries @@ -67,17 +72,24 @@ def get_artisanal_manager(bin_folder_path: str, executable_name: str) -> Artisan def fetch_binary(self): # Check cache if self.is_built(): + logger.info(f'Binary for {self.state.index} is already in place') + return + # Consult binary cache + elif BinaryCache.fetch_binary_files(self.get_potential_bin_path(), self.state): + logger.info(f'Binary for {self.state.index} fetched from cache') return # Try to download binary elif self.is_available_online(): self.download_binary() + logger.info(f'Binary for {self.state.index} downloaded') + BinaryCache.store_binary_files(self.get_potential_bin_path(), self.state) else: raise BuildNotAvailableError(self.browser_name, self.state) def is_available(self): - ''' + """ Returns True if the binary is available either locally or online. - ''' + """ return self.is_available_locally() or self.is_available_online() def is_available_locally(self): @@ -95,7 +107,7 @@ def is_built(self): bin_path = self.get_bin_path() return bin_path is not None - def get_bin_path(self): + def get_bin_path(self) -> Optional[str]: """ Returns path to binary, only if the binary is available locally. Otherwise it returns None. """ @@ -112,8 +124,8 @@ def get_potential_bin_path(self, artisanal=False): Returns path to potential binary. It does not guarantee whether the binary is available locally. """ if artisanal: - return os.path.join(self.bin_folder_path, "artisanal", self.state.name, self.executable_name) - return os.path.join(self.bin_folder_path, "downloaded", self.state.name, self.executable_name) + return os.path.join(self.bin_folder_path, 'artisanal', self.state.name, self.executable_name) + return os.path.join(self.bin_folder_path, 'downloaded', self.state.name, self.executable_name) def get_bin_folder_path(self): path_downloaded = self.get_potential_bin_folder_path() @@ -126,25 +138,20 @@ def get_bin_folder_path(self): def get_potential_bin_folder_path(self, artisanal=False): if artisanal: - return os.path.join(self.bin_folder_path, "artisanal", self.state.name) - return os.path.join(self.bin_folder_path, "downloaded", self.state.name) + return os.path.join(self.bin_folder_path, 'artisanal', self.state.name) + return os.path.join(self.bin_folder_path, 'downloaded', self.state.name) def remove_bin_folder(self): path = self.get_bin_folder_path() - if path and "artisanal" not in path: + if path and 'artisanal' not in path: if not util.rmtree(path): logger.error("Could not remove folder '%s'" % path) @abstractmethod - def get_driver_version(self, browser_version): - pass - - @abstractmethod - def _get_version(self): + def _get_version(self) -> str: pass class BuildNotAvailableError(Exception): - def __init__(self, browser_name, build_state): - super().__init__("Browser build not available: %s (%s)" % (browser_name, build_state)) + super().__init__('Browser build not available: %s (%s)' % (browser_name, build_state)) diff --git a/bci/browser/binary/factory.py b/bci/browser/binary/factory.py index 1c732a3..2206d77 100644 --- a/bci/browser/binary/factory.py +++ b/bci/browser/binary/factory.py @@ -1,3 +1,5 @@ +from typing import Type + from bci.browser.binary.binary import Binary from bci.browser.binary.vendors.chromium import ChromiumBinary from bci.browser.binary.vendors.firefox import FirefoxBinary @@ -36,7 +38,7 @@ def get_binary(state: State) -> Binary: return __get_object(state) -def __get_class(browser_name: str) -> Binary.__class__: +def __get_class(browser_name: str) -> Type[Binary]: match browser_name: case 'chromium': return ChromiumBinary diff --git a/bci/browser/binary/vendors/chromium.py b/bci/browser/binary/vendors/chromium.py index 75ab2c2..8511b73 100644 --- a/bci/browser/binary/vendors/chromium.py +++ b/bci/browser/binary/vendors/chromium.py @@ -11,7 +11,7 @@ from bci.browser.binary.binary import Binary from bci.version_control.states.state import State -logger = logging.getLogger('bci') +logger = logging.getLogger(__name__) EXECUTABLE_NAME = 'chrome' BIN_FOLDER_PATH = '/app/browser/binaries/chromium' @@ -74,27 +74,16 @@ def download_binary(self): shutil.rmtree(os.path.dirname(zip_file_path)) def _get_version(self) -> str: - bin_path = self.get_bin_path() command = "./chrome --version" - output = cli.execute_and_return_output(command, cwd=os.path.dirname(bin_path)) + if bin_path := self.get_bin_path(): + output = cli.execute_and_return_output(command, cwd=os.path.dirname(bin_path)) + else: + raise AttributeError(f'Could not get binary path for {self.state}') match = re.match(r'Chromium (?P[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)', output) if match: return match.group("version") raise AttributeError("Could not determine version of binary at '%s'. Version output: %s" % (bin_path, output)) - def get_driver_path(self, full_browser_version): - driver_version = self.get_driver_version(full_browser_version) - driver_path = os.path.join(DRIVER_FOLDER_PATH, driver_version) - if os.path.exists(driver_path): - return driver_path - raise AttributeError("Could not find appropriate driver for Chromium %s" % full_browser_version) - - def get_driver_version(self, browser_version): - short_browser_version = browser_version.split('.')[0] - if short_browser_version not in self.browser_version_to_driver_version.keys(): - raise AttributeError("Could not determine driver version associated with Chromium version %s" % browser_version) - return self.browser_version_to_driver_version[short_browser_version] - @staticmethod def list_downloaded_binaries() -> list[dict[str, str]]: return Binary.list_downloaded_binaries(BIN_FOLDER_PATH) diff --git a/bci/browser/binary/vendors/firefox.py b/bci/browser/binary/vendors/firefox.py index 1c1fb4e..c928e65 100644 --- a/bci/browser/binary/vendors/firefox.py +++ b/bci/browser/binary/vendors/firefox.py @@ -9,8 +9,6 @@ from bci import cli, util from bci.browser.binary.artisanal_manager import ArtisanalBuildManager from bci.browser.binary.binary import Binary -from bci.version_control.states.revisions.firefox import (BINARY_AVAILABILITY_MAPPING, - REVISION_NUMBER_MAPPING) from bci.version_control.states.state import State logger = logging.getLogger('bci') diff --git a/bci/configuration.py b/bci/configuration.py index 3d8e31b..c8472c0 100644 --- a/bci/configuration.py +++ b/bci/configuration.py @@ -4,13 +4,12 @@ import sys import bci.database.mongo.container as container -from bci.evaluations.logic import DatabaseConnectionParameters +from bci.evaluations.logic import DatabaseParameters logger = logging.getLogger(__name__) class Global: - custom_page_folder = '/app/experiments/pages' @staticmethod @@ -25,7 +24,7 @@ def get_browser_config_class(browser: str): case 'firefox': return Firefox case _: - raise ValueError(f'Invalid browser \'{browser}\'') + raise ValueError(f"Invalid browser '{browser}'") @staticmethod def get_available_domains() -> list[str]: @@ -44,7 +43,9 @@ def check_required_env_parameters() -> bool: fatal = False # HOST_PWD if (host_pwd := os.getenv('HOST_PWD')) in ['', None]: - logger.fatal('The "HOST_PWD" variable is not set. If you\'re using sudo, you might have to pass it explicitly, for example "sudo HOST_PWD=$PWD docker compose up".') + logger.fatal( + 'The "HOST_PWD" variable is not set. If you\'re using sudo, you might have to pass it explicitly, for example "sudo HOST_PWD=$PWD docker compose up".' + ) fatal = True else: logger.debug(f'HOST_PWD={host_pwd}') @@ -66,54 +67,49 @@ def initialize_folders(): file.write('{}') @staticmethod - def get_database_connection_params() -> DatabaseConnectionParameters: - required_database_params = [ - 'BCI_MONGO_HOST', - 'BCI_MONGO_USERNAME', - 'BCI_MONGO_DATABASE', - 'BCI_MONGO_PASSWORD' - ] - missing_database_params = [ - param for param in required_database_params - if os.getenv(param) in ['', None]] + def get_database_params() -> DatabaseParameters: + required_database_params = ['BCI_MONGO_HOST', 'BCI_MONGO_USERNAME', 'BCI_MONGO_DATABASE', 'BCI_MONGO_PASSWORD'] + missing_database_params = [param for param in required_database_params if os.getenv(param) in ['', None]] if missing_database_params: logger.info(f'Could not find database parameters {missing_database_params}, using database container...') return container.run() else: - database_params = DatabaseConnectionParameters( + database_params = DatabaseParameters( os.getenv('BCI_MONGO_HOST'), os.getenv('BCI_MONGO_USERNAME'), os.getenv('BCI_MONGO_PASSWORD'), - os.getenv('BCI_MONGO_DATABASE') + os.getenv('BCI_MONGO_DATABASE'), + int(os.getenv('BCI_BINARY_CACHE_LIMIT', 0)), ) - logger.info(f'Found database environment variables \'{database_params}\'') + logger.info(f"Found database environment variables '{database_params}'") return database_params @staticmethod def get_tag() -> str: - ''' + """ Returns the Docker image tag of BugHog. This should never be empty. - ''' - assert (bughog_version := os.getenv('BUGHOG_VERSION')) not in ['', None] + """ + bughog_version = os.getenv('BUGHOG_VERSION', None) + if bughog_version is None or bughog_version == '': + raise ValueError('BUGHOG_VERSION is not set') return bughog_version class Chromium: - extension_folder = '/app/browser/extensions/chromium' repo_to_use = 'online' class Firefox: - extension_folder = '/app/browser/extensions/firefox' repo_to_use = 'online' class CustomHTTPHandler(logging.handlers.HTTPHandler): - - def __init__(self, host: str, url: str, method: str = 'GET', secure: bool = False, credentials=None, context=None) -> None: + def __init__( + self, host: str, url: str, method: str = 'GET', secure: bool = False, credentials=None, context=None + ) -> None: super().__init__(host, url, method=method, secure=secure, credentials=credentials, context=context) self.hostname = os.getenv('HOSTNAME') @@ -124,8 +120,9 @@ def mapLogRecord(self, record): class Loggers: - - formatter = logging.Formatter(fmt='[%(asctime)s] [%(levelname)s] %(name)s: %(message)s', datefmt='%d-%m-%Y %H:%M:%S') + formatter = logging.Formatter( + fmt='[%(asctime)s] [%(levelname)s] %(name)s: %(message)s', datefmt='%d-%m-%Y %H:%M:%S' + ) memory_handler = logging.handlers.MemoryHandler(capacity=100, flushLevel=logging.ERROR) @staticmethod diff --git a/bci/database/mongo/binary_cache.py b/bci/database/mongo/binary_cache.py new file mode 100644 index 0000000..7a98434 --- /dev/null +++ b/bci/database/mongo/binary_cache.py @@ -0,0 +1,144 @@ +import concurrent.futures +import datetime +import logging +import os +import time +from typing import Optional + +from bci.database.mongo.mongodb import MongoDB +from bci.version_control.states.state import State + +logger = logging.getLogger(__name__) + + +class BinaryCache: + """ + The binary cache is used to store and fetch binary files from the database. + """ + + @staticmethod + def fetch_binary_files(binary_executable_path: str, state: State) -> bool: + """ + Fetches the binary files from the database and stores them in the directory of the given path. + + :param binary_executable_path: The path to store the executable binary file. + :param state: The state of the binary. + :return: True if the binary was fetched, False otherwise. + """ + if MongoDB().binary_cache_limit <= 0: + return False + + files_collection = MongoDB().get_collection('fs.files') + + query = { + 'file_type': 'binary', + 'browser_name': state.browser_name, + 'state_type': state.type, + 'state_index': state.index, + } + if files_collection.count_documents(query) == 0: + return False + # Update access count and last access timestamp + files_collection.update_many( + query, + {'$inc': {'access_count': 1}, '$set': {'last_access_ts': datetime.datetime.now()}}, + ) + binary_folder_path = os.path.dirname(binary_executable_path) + if not os.path.exists(binary_folder_path): + os.mkdir(binary_folder_path) + + def write_from_db(file_path: str, grid_file_id: str) -> None: + grid_file = fs.get(grid_file_id) + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, 'wb') as file: + file.write(grid_file.read()) + os.chmod(file_path, 0o744) + + grid_cursor = files_collection.find(query) + fs = MongoDB().gridfs + start_time = time.time() + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: + for grid_doc in grid_cursor: + file_path = os.path.join(binary_folder_path, grid_doc['relative_file_path']) + grid_file_id = grid_doc['_id'] + executor.submit(write_from_db, file_path, grid_file_id) + + executor.shutdown(wait=True) + elapsed_time = time.time() - start_time + logger.debug(f'Fetched cached binary in {elapsed_time:.2f}s') + return True + + @staticmethod + def store_binary_files(binary_executable_path: str, state: State) -> bool: + """ + Stores the files in the folder of the given path in the database. + + :param binary_executable_path: The path to the binary executable. + :param state: The state of the binary. + :return: True if the binary was stored, False otherwise. + """ + if MongoDB().binary_cache_limit <= 0: + return False + + while BinaryCache.__count_cached_binaries() >= MongoDB.binary_cache_limit: + if BinaryCache.__count_cached_binaries(state_type='revision') <= 0: + # There are only version binaries in the cache, which will never be removed + return False + BinaryCache.__remove_least_used_revision_binary_files() + + fs = MongoDB().gridfs + binary_folder_path = os.path.dirname(binary_executable_path) + start_time = time.time() + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: + for root, _, files in os.walk(binary_folder_path): + for file in files: + file_path = os.path.join(root, file) + with open(file_path, 'rb') as file: + executor.submit( + fs.put, + file.read(), + file_type='binary', + browser_name=state.browser_name, + state_type=state.type, + state_index=state.index, + relative_file_path=os.path.relpath(file_path, binary_folder_path), + access_count=0, + last_access_ts=datetime.datetime.now(), + ) + executor.shutdown(wait=True) + elapsed_time = time.time() - start_time + logger.debug(f'Stored binary in {elapsed_time:.2f}s') + return True + + @staticmethod + def __count_cached_binaries(state_type: Optional[str] = None) -> int: + """ + Counts the number of cached binaries in the database. + + :param state_type: The type of the state. + :return: The number of cached binaries. + """ + files_collection = MongoDB().get_collection('fs.files') + if state_type: + query = {'file_type': 'binary', 'state_type': state_type} + else: + query = {'file_type': 'binary'} + return len(files_collection.find(query).distinct('state_index')) + + @staticmethod + def __remove_least_used_revision_binary_files() -> None: + """ + Removes the least used revision binary files from the database. + """ + fs = MongoDB().gridfs + files_collection = MongoDB().get_collection('fs.files') + + grid_cursor = files_collection.find( + {'file_type': 'binary', 'state_type': 'revision'}, + sort=[('access_count', 1), ('last_access_ts', 1)], + ) + for state_doc in grid_cursor: + state_index = state_doc['state_index'] + for grid_doc in files_collection.find({'state_index': state_index, 'state_type': 'revision'}): + fs.delete(grid_doc['_id']) + break diff --git a/bci/database/mongo/container.py b/bci/database/mongo/container.py index b747172..16e6b45 100644 --- a/bci/database/mongo/container.py +++ b/bci/database/mongo/container.py @@ -5,7 +5,7 @@ import docker.errors from pymongo import MongoClient -from bci.evaluations.logic import DatabaseConnectionParameters +from bci.evaluations.logic import DatabaseParameters LOGGER = logging.getLogger(__name__) @@ -20,7 +20,7 @@ DEFAULT_HOST = 'bh_db' -def run() -> DatabaseConnectionParameters: +def run() -> DatabaseParameters: docker_client = docker.from_env() try: mongo_container = docker_client.containers.get(DEFAULT_HOST) @@ -33,12 +33,8 @@ def run() -> DatabaseConnectionParameters: LOGGER.debug('MongoDB container not found, creating a new one...') __create_new_container(DEFAULT_USER, DEFAULT_PW, DEFAULT_DB_NAME, DEFAULT_HOST) LOGGER.debug('MongoDB container has started!') - return DatabaseConnectionParameters( - DEFAULT_HOST, - DEFAULT_USER, - DEFAULT_PW, - DEFAULT_DB_NAME - ) + return DatabaseParameters(DEFAULT_HOST, DEFAULT_USER, DEFAULT_PW, DEFAULT_DB_NAME, 0) + def stop(): docker_client = docker.from_env() @@ -49,25 +45,23 @@ def stop(): except docker.errors.NotFound: LOGGER.debug('No MongoDB container found to stop. This is OK if another MongoDB instance is used.') + def __create_new_container(user: str, pw: str, db_name, db_host): + if (host_pwd := os.getenv('HOST_PWD', None)) is None: + raise AttributeError("Could not create container because of missing HOST_PWD environment variable") docker_client = docker.from_env() docker_client.containers.run( - 'mongo:5.0.17', - name=db_host, - hostname=db_host, - network=NETWORK_NAME, - detach=True, - remove=True, - labels=['bh_db'], - volumes=[ - os.path.join(os.getenv('HOST_PWD'), 'database/data') + ':/data/db' - ], - ports={'27017/tcp': 27017}, - environment={ - 'MONGO_INITDB_ROOT_USERNAME': DEFAULT_ROOT_USER, - 'MONGO_INITDB_ROOT_PASSWORD': DEFAULT_ROOT_PW - } - ) + 'mongo:5.0.17', + name=db_host, + hostname=db_host, + network=NETWORK_NAME, + detach=True, + remove=True, + labels=['bh_db'], + volumes=[os.path.join(host_pwd, 'database/data') + ':/data/db'], + ports={'27017/tcp': 27017}, + environment={'MONGO_INITDB_ROOT_USERNAME': DEFAULT_ROOT_USER, 'MONGO_INITDB_ROOT_PASSWORD': DEFAULT_ROOT_PW}, + ) mongo_client = MongoClient( host=db_host, @@ -75,16 +69,9 @@ def __create_new_container(user: str, pw: str, db_name, db_host): username=DEFAULT_ROOT_USER, password=DEFAULT_ROOT_PW, authsource='admin', - retryWrites=False + retryWrites=False, ) db = mongo_client[db_name] if not db.command('usersInfo', user)['users']: - db.command( - 'createUser', - user, - pwd=pw, - roles=[{ - 'role': 'readWrite', - 'db': db_name - }]) + db.command('createUser', user, pwd=pw, roles=[{'role': 'readWrite', 'db': db_name}]) diff --git a/bci/database/mongo/mongodb.py b/bci/database/mongo/mongodb.py index 7918776..6b87583 100644 --- a/bci/database/mongo/mongodb.py +++ b/bci/database/mongo/mongodb.py @@ -1,90 +1,130 @@ from __future__ import annotations import logging -from abc import ABC from datetime import datetime, timezone +from typing import Optional from flatten_dict import flatten -from pymongo import MongoClient +from gridfs import GridFS +from pymongo import ASCENDING, MongoClient from pymongo.collection import Collection +from pymongo.database import Database from pymongo.errors import ServerSelectionTimeoutError -from bci.evaluations.logic import (DatabaseConnectionParameters, - PlotParameters, TestParameters, TestResult, - WorkerParameters) -from bci.version_control.states.state import State +from bci.evaluations.logic import ( + DatabaseParameters, + EvaluationParameters, + PlotParameters, + StateResult, + TestParameters, + TestResult, + WorkerParameters, +) +from bci.evaluations.outcome_checker import OutcomeChecker +from bci.version_control.states.state import State, StateCondition logger = logging.getLogger(__name__) -# pylint: disable=global-statement -CLIENT = None -DB = None +def singleton(class_): + instances = {} -class MongoDB(ABC): + def get_instance(*args, **kwargs): + if class_ not in instances: + instances[class_] = class_(*args, **kwargs) + return instances[class_] + + return get_instance + + +@singleton +class MongoDB: instance = None + binary_cache_limit = 0 binary_availability_collection_names = { - "chromium": "chromium_binary_availability", - "firefox": "firefox_central_binary_availability" + 'chromium': 'chromium_binary_availability', + 'firefox': 'firefox_central_binary_availability', } def __init__(self): - self.client = CLIENT - self.db = DB - - @classmethod - def get_instance(cls) -> MongoDB: - if cls.instance is None: - cls.instance = cls() - return cls.instance - - @staticmethod - def connect(db_connection_params: DatabaseConnectionParameters): - global CLIENT, DB - assert db_connection_params is not None - - CLIENT = MongoClient( - host=db_connection_params.host, + self.client: Optional[MongoClient] = None + self._db: Optional[Database] = None + + def connect(self, db_params: DatabaseParameters) -> None: + assert db_params is not None + + self.client = MongoClient( + host=db_params.host, port=27017, - username=db_connection_params.username, - password=db_connection_params.password, - authsource=db_connection_params.database_name, + username=db_params.username, + password=db_params.password, + authsource=db_params.database_name, retryWrites=False, - serverSelectionTimeoutMS=10000) + serverSelectionTimeoutMS=10000, + ) + logger.info(f'Binary cache limit set to {db_params.binary_cache_limit}') # Force connection to check whether MongoDB server is reachable try: - CLIENT.server_info() - DB = CLIENT[db_connection_params.database_name] - logger.info("Connected to database!") + self.client.server_info() + self._db = self.client[db_params.database_name] + logger.info('Connected to database!') except ServerSelectionTimeoutError as e: - logger.info("A timeout occurred while attempting to establish connection.", exc_info=True) + logger.info('A timeout occurred while attempting to establish connection.', exc_info=True) raise ServerException from e # Initialize collections - MongoDB.__initialize_collections() - - @staticmethod - def disconnect(): - global CLIENT, DB - CLIENT.close() - CLIENT = None - DB = None - - @staticmethod - def __initialize_collections(): - for collection_name in [ - 'chromium_binary_availability', - 'firefox_central_binary_availability' - ]: - if collection_name not in DB.list_collection_names(): - DB.create_collection(collection_name) - - def get_collection(self, name: str): - if name not in DB.list_collection_names(): - logger.info(f'Collection \'{name}\' does not exist, creating it...') - DB.create_collection(name) - return DB[name] + self.__initialize_collections() + + def disconnect(self): + if self.client: + self.client.close() + self.client = None + self._db = None + + def __initialize_collections(self): + if self._db is None: + raise + + for collection_name in ['chromium_binary_availability']: + if collection_name not in self._db.list_collection_names(): + self._db.create_collection(collection_name) + + # Binary cache + if 'fs.files' not in self._db.list_collection_names(): + # Create the 'fs.files' collection with indexes + self._db.create_collection('fs.files') + self._db['fs.files'].create_index( + ['state_type', 'browser_name', 'state_index', 'relative_file_path'], unique=True + ) + if 'fs.chunks' not in self._db.list_collection_names(): + # Create the 'fs.chunks' collection with zstd compression + self._db.create_collection( + 'fs.chunks', storageEngine={'wiredTiger': {'configString': 'block_compressor=zstd'}} + ) + self._db['fs.chunks'].create_index(['files_id', 'n'], unique=True) + + # Revision cache + if 'firefox_binary_availability' not in self._db.list_collection_names(): + self._db.create_collection('firefox_binary_availability') + self._db['firefox_binary_availability'].create_index([('revision_number', ASCENDING)]) + self._db['firefox_binary_availability'].create_index(['node']) + + def get_collection(self, name: str, create_if_not_found: bool = False) -> Collection: + if self._db is None: + raise ServerException('Database server does not have a database') + if name not in self._db.list_collection_names(): + if create_if_not_found: + return self._db.create_collection(name) + else: + raise ServerException(f"Could not find collection '{name}'") + return self._db[name] + + @property + def gridfs(self) -> GridFS: + if self._db is None: + raise ServerException('Database server does not have a database') + return GridFS(self._db) def store_result(self, result: TestResult): browser_config = result.params.browser_configuration @@ -102,34 +142,32 @@ def store_result(self, result: TestResult): 'mech_group': result.params.mech_group, 'results': result.data, 'dirty': result.is_dirty, - 'ts': str(datetime.now(timezone.utc).replace(microsecond=0)) + 'ts': str(datetime.now(timezone.utc).replace(microsecond=0)), } if result.driver_version: - document["driver_version"] = result.driver_version + document['driver_version'] = result.driver_version - if browser_config.browser_name == "firefox": + if browser_config.browser_name == 'firefox': build_id = self.get_build_id_firefox(result.params.state) if build_id is None: - document["artisanal"] = True - document["build_id"] = "artisanal" + document['artisanal'] = True + document['build_id'] = 'artisanal' else: - document["build_id"] = build_id + document['build_id'] = build_id collection.insert_one(document) - def get_result(self, params: TestParameters) -> TestResult: + def get_result(self, params: TestParameters) -> Optional[TestResult]: collection = self.__get_data_collection(params) query = self.__to_query(params) document = collection.find_one(query) if document: return params.create_test_result_with( - document['browser_version'], - document['binary_origin'], - document['results'], - document['dirty'] + document['browser_version'], document['binary_origin'], document['results'], document['dirty'] ) else: logger.error(f'Could not find document for query {query}') + return None def has_result(self, params: TestParameters) -> bool: collection = self.__get_data_collection(params) @@ -137,30 +175,66 @@ def has_result(self, params: TestParameters) -> bool: nb_of_documents = collection.count_documents(query) return nb_of_documents > 0 - def has_all_results(self, params: WorkerParameters) -> bool: - for test_params in map(params.create_test_params_for, params.mech_groups): - if not self.has_result(test_params): - return False - return True + def get_evaluated_states( + self, params: EvaluationParameters, boundary_states: tuple[State, State], outcome_checker: OutcomeChecker + ) -> list[State]: + collection = self.get_collection(params.database_collection) + query = { + 'browser_config': params.browser_configuration.browser_setting, + 'mech_group': params.evaluation_range.mech_group, + 'state.browser_name': params.browser_configuration.browser_name, + 'results': {'$exists': True}, + 'state.type': 'version' if params.evaluation_range.only_release_revisions else 'revision', + 'state.revision_number': { + '$gte': boundary_states[0].revision_nb, + '$lte': boundary_states[1].revision_nb, + }, + } + if params.browser_configuration.extensions: + query['extensions'] = { + '$size': len(params.browser_configuration.extensions), + '$all': params.browser_configuration.extensions, + } + else: + query['extensions'] = [] + if params.browser_configuration.cli_options: + query['cli_options'] = { + '$size': len(params.browser_configuration.cli_options), + '$all': params.browser_configuration.cli_options, + } + else: + query['cli_options'] = [] + cursor = collection.find(query) + states = [] + for doc in cursor: + state = State.from_dict(doc['state']) + state.result = StateResult.from_dict(doc['results'], is_dirty=doc['dirty']) + state.outcome = outcome_checker.get_outcome(state.result) + if doc['dirty']: + state.condition = StateCondition.FAILED + else: + state.condition = StateCondition.COMPLETED + states.append(state) + return states def __to_query(self, params: TestParameters) -> dict: query = { 'state': params.state.to_dict(), 'browser_automation': params.evaluation_configuration.automation, 'browser_config': params.browser_configuration.browser_setting, - 'mech_group': params.mech_group + 'mech_group': params.mech_group, } if len(params.browser_configuration.extensions) > 0: query['extensions'] = { '$size': len(params.browser_configuration.extensions), - '$all': params.browser_configuration.extensions + '$all': params.browser_configuration.extensions, } else: query['extensions'] = [] if len(params.browser_configuration.cli_options) > 0: query['cli_options'] = { '$size': len(params.browser_configuration.cli_options), - '$all': params.browser_configuration.cli_options + '$all': params.browser_configuration.cli_options, } else: query['cli_options'] = [] @@ -168,154 +242,109 @@ def __to_query(self, params: TestParameters) -> dict: def __get_data_collection(self, test_params: TestParameters) -> Collection: collection_name = test_params.database_collection - if collection_name not in self.db.list_collection_names(): - return self.db.create_collection(collection_name) - return self.db[collection_name] + return self.get_collection(collection_name, create_if_not_found=True) - @staticmethod - def get_binary_availability_collection(browser_name: str): - collection_name = MongoDB.binary_availability_collection_names[browser_name] - if collection_name not in DB.list_collection_names(): - raise AttributeError("Collection '%s' not found in database" % collection_name) - return DB[collection_name] + def get_binary_availability_collection(self, browser_name: str): + collection_name = self.binary_availability_collection_names[browser_name] + return self.get_collection(collection_name, create_if_not_found=True) # Caching of online binary availability - @staticmethod - def has_binary_available_online(browser: str, state: State): - collection = MongoDB.get_binary_availability_collection(browser) - document = collection.find_one({'state': state.to_dict(make_complete=False)}) + def has_binary_available_online(self, browser: str, state: State): + collection = self.get_binary_availability_collection(browser) + document = collection.find_one({'state': state.to_dict()}) if document is None: return None - return document["binary_online"] + return document['binary_online'] - @staticmethod - def get_stored_binary_availability(browser): - collection = MongoDB.get_binary_availability_collection(browser) + def get_stored_binary_availability(self, browser): + collection = MongoDB().get_binary_availability_collection(browser) result = collection.find( + {'binary_online': True}, { - "binary_online": True + '_id': False, + 'state': True, }, - { - "_id": False, - "state": True, - } ) - if browser == "firefox": + if browser == 'firefox': result.sort('build_id', -1) return result - @staticmethod - def get_complete_state_dict_from_binary_availability_cache(state: State): - collection = MongoDB.get_binary_availability_collection(state.browser_name) + def get_complete_state_dict_from_binary_availability_cache(self, state: State) -> Optional[dict]: + collection = MongoDB().get_binary_availability_collection(state.browser_name) # We have to flatten the state dictionary to ignore missing attributes. - state_dict = { - 'state': state.to_dict(make_complete=False) - } + state_dict = {'state': state.to_dict()} query = flatten(state_dict, reducer='dot') document = collection.find_one(query) if document is None: return None return document['state'] - @staticmethod - def store_binary_availability_online_cache(browser: str, state: State, binary_online: bool, url: str = None): - collection = MongoDB.get_binary_availability_collection(browser) + def store_binary_availability_online_cache( + self, browser: str, state: State, binary_online: bool, url: Optional[str] = None + ): + collection = MongoDB().get_binary_availability_collection(browser) collection.update_one( + {'state': state.to_dict()}, { - 'state': state.to_dict() - }, - { - "$set": - { + '$set': { 'state': state.to_dict(), 'binary_online': binary_online, 'url': url, - 'ts': str(datetime.now(timezone.utc).replace(microsecond=0)) + 'ts': str(datetime.now(timezone.utc).replace(microsecond=0)), } }, - upsert=True + upsert=True, ) - @staticmethod - def get_build_id_firefox(state: State): - collection = MongoDB.get_binary_availability_collection("firefox") + def get_build_id_firefox(self, state: State): + collection = MongoDB().get_binary_availability_collection('firefox') - result = collection.find_one({ - "state": state.to_dict() - }, { - "_id": False, - "build_id": 1 - }) + result = collection.find_one({'state': state.to_dict()}, {'_id': False, 'build_id': 1}) # Result can only be None if the binary associated with the state_id is artisanal: # This state_id will not be included in the binary_availability_collection and not have a build_id. if result is None or len(result) == 0: return None - return result["build_id"] + return result['build_id'] def get_documents_for_plotting(self, params: PlotParameters, releases: bool = False): collection = self.get_collection(params.database_collection) query = { 'mech_group': params.mech_group, 'browser_config': params.browser_config, - 'state.type': 'version' if releases else 'revision' - } - query['extensions'] = { - '$size': len(params.extensions) if params.extensions else 0 + 'state.type': 'version' if releases else 'revision', + 'extensions': {'$size': len(params.extensions) if params.extensions else 0}, + 'cli_options': {'$size': len(params.cli_options) if params.cli_options else 0} } if params.extensions: query['extensions']['$all'] = params.extensions - query['cli_options'] = { - '$size': len(params.cli_options) if params.cli_options else 0 - } if params.cli_options: query['cli_options']['$all'] = params.cli_options if params.revision_number_range: query['state.revision_number'] = { '$gte': params.revision_number_range[0], - '$lte': params.revision_number_range[1] + '$lte': params.revision_number_range[1], } elif params.major_version_range: query['padded_browser_version'] = { '$gte': str(params.major_version_range[0]).zfill(4), - '$lte': str(params.major_version_range[1] + 1).zfill(4) + '$lte': str(params.major_version_range[1] + 1).zfill(4), } - docs = collection.aggregate([ - { - '$match': query - }, - { - '$project': { - '_id': False, - 'state': True, - 'browser_version': True, - 'dirty': True, - 'results': True - } - }, - { - '$sort': { - 'rev_nb': 1 - } - } - ]) + docs = collection.aggregate( + [ + {'$match': query}, + {'$project': {'_id': False, 'state': True, 'browser_version': True, 'dirty': True, 'results': True}}, + {'$sort': {'rev_nb': 1}}, + ] + ) return list(docs) - @staticmethod - def get_info() -> dict: - if CLIENT and CLIENT.address: - return { - 'type': 'mongo', - 'host': CLIENT.address[0], - 'connected': True - } + def get_info(self) -> dict: + if self.client and self.client.address: + return {'type': 'mongo', 'host': self.client.address[0], 'connected': True} else: - return { - 'type': 'mongo', - 'host': None, - 'connected': False - } + return {'type': 'mongo', 'host': None, 'connected': False} class ServerException(Exception): diff --git a/bci/database/mongo/revision_cache.py b/bci/database/mongo/revision_cache.py new file mode 100644 index 0000000..08cb5d4 --- /dev/null +++ b/bci/database/mongo/revision_cache.py @@ -0,0 +1,66 @@ +import logging +from typing import Optional + +from pymongo import ASCENDING, DESCENDING + +from bci.database.mongo.mongodb import MongoDB + +logger = logging.getLogger(__name__) + + +class RevisionCache: + @staticmethod + def store_firefox_binary_availability(data: dict) -> None: + values = list(data.values()) + collection = MongoDB().get_collection('firefox_binary_availability') + + if (n := len(values)) == collection.count_documents({}): + logger.debug(f'Revision Cache was not updated ({n} documents).') + return + + collection.delete_many({}) + collection.insert_many(values) + logger.info(f'Revision Cache was updates ({len(values)} documents).') + + @staticmethod + def firefox_get_revision_number(revision_id: str) -> int: + collection = MongoDB().get_collection('firefox_binary_availability') + result = collection.find_one({'revision_id': revision_id}, {'revision_number': 1}) + if result is None or 'revision_number' not in result: + raise AttributeError(f"Could not find 'revision_number' in {result}") + return result['revision_number'] + + @staticmethod + def firefox_has_binary_for(revision_nb: Optional[int], revision_id: Optional[str]) -> bool: + collection = MongoDB().get_collection('firefox_binary_availability') + if revision_nb: + result = collection.find_one({'revision_number': revision_nb}) + elif revision_id: + result = collection.find_one({'revision_number': revision_nb}) + else: + raise AttributeError('No revision number or id was provided') + return result is not None + + @staticmethod + def firefox_get_binary_info(revision_id: str) -> Optional[dict]: + collection = MongoDB().get_collection('firefox_binary_availability') + return collection.find_one({'revision_id': revision_id}, {'files_url': 1, 'app_version': 1}) + + @staticmethod + def firefox_get_previous_and_next_revision_nb_with_binary(revision_nb: int) -> tuple[Optional[int], Optional[int]]: + collection = MongoDB().get_collection('firefox_binary_availability') + + previous_revision_nbs = collection.find({'revision_number': {'$lt': revision_nb}}).sort( + {'revision_number': DESCENDING} + ) + previous_document = next(previous_revision_nbs, None) + + next_revision_nbs = collection.find({'revision_number': {'$gt': revision_nb}}).sort( + {'revision_number': ASCENDING} + ) + next_document = next(next_revision_nbs, None) + + return ( + previous_document['revision_number'] if previous_document else None, + next_document['revision_number'] if next_document else None, + ) diff --git a/bci/distribution/worker_manager.py b/bci/distribution/worker_manager.py index feb35f7..e619401 100644 --- a/bci/distribution/worker_manager.py +++ b/bci/distribution/worker_manager.py @@ -3,7 +3,6 @@ import threading import time from queue import Queue -from typing import Callable import docker import docker.errors @@ -11,8 +10,9 @@ from bci import worker from bci.configuration import Global from bci.evaluations.logic import WorkerParameters +from bci.web.clients import Clients -logger = logging.getLogger('bci') +logger = logging.getLogger(__name__) class WorkerManager: @@ -27,19 +27,16 @@ def __init__(self, max_nb_of_containers: int) -> None: self.container_id_pool.put(i) self.client = docker.from_env() - def start_test(self, params: WorkerParameters, cb: Callable, blocking_wait=True) -> None: + def start_test(self, params: WorkerParameters, blocking_wait=True) -> None: if self.max_nb_of_containers != 1: - return self.__run_container(params, cb, blocking_wait) + return self.__run_container(params, blocking_wait) # Single container mode worker.run(params) - cb() + Clients.push_results_to_all() - def __run_container(self, params: WorkerParameters, cb: Callable, blocking_wait=True) -> None: - while ( - blocking_wait - and self.get_nb_of_running_worker_containers() >= self.max_nb_of_containers - ): + def __run_container(self, params: WorkerParameters, blocking_wait=True) -> None: + while blocking_wait and self.get_nb_of_running_worker_containers() >= self.max_nb_of_containers: time.sleep(5) container_id = self.container_id_pool.get() container_name = f'bh_worker_{container_id}' @@ -54,7 +51,7 @@ def start_container_thread(): ignore_removed=True, filters={ 'name': f'^/{container_name}$' # The exact name has to match - } + }, ) # Break loop if no container with same name is active if not active_containers: @@ -63,6 +60,8 @@ def start_container_thread(): for container in active_containers: logger.info(f'Removing old container \'{container.attrs["Name"]}\' to start new one') container.remove(force=True) + if (host_pwd := os.getenv('HOST_PWD', None)) is None: + raise AttributeError('Could not find HOST_PWD environment var') self.client.containers.run( f'bughog/worker:{Global.get_tag()}', name=container_name, @@ -75,34 +74,41 @@ def start_container_thread(): labels=['bh_worker'], command=[params.serialize()], volumes=[ - os.path.join(os.getenv('HOST_PWD'), 'config') + ':/app/config:ro', - os.path.join(os.getenv('HOST_PWD'), 'browser/binaries/chromium/artisanal') + ':/app/browser/binaries/chromium/artisanal:rw', - os.path.join(os.getenv('HOST_PWD'), 'browser/binaries/firefox/artisanal') + ':/app/browser/binaries/firefox/artisanal:rw', - os.path.join(os.getenv('HOST_PWD'), 'experiments') + ':/app/experiments:ro', - os.path.join(os.getenv('HOST_PWD'), 'browser/extensions') + ':/app/browser/extensions:ro', - os.path.join(os.getenv('HOST_PWD'), 'logs') + ':/app/logs:rw', - os.path.join(os.getenv('HOST_PWD'), 'nginx/ssl') + ':/etc/nginx/ssl:ro', + os.path.join(host_pwd, 'config') + ':/app/config:ro', + os.path.join(host_pwd, 'browser/binaries/chromium/artisanal') + + ':/app/browser/binaries/chromium/artisanal:rw', + os.path.join(host_pwd, 'browser/binaries/firefox/artisanal') + + ':/app/browser/binaries/firefox/artisanal:rw', + os.path.join(host_pwd, 'experiments') + ':/app/experiments:ro', + os.path.join(host_pwd, 'browser/extensions') + ':/app/browser/extensions:ro', + os.path.join(host_pwd, 'logs') + ':/app/logs:rw', + os.path.join(host_pwd, 'nginx/ssl') + ':/etc/nginx/ssl:ro', '/dev/shm:/dev/shm', ], ) - logger.debug(f'Container \'{container_name}\' finished experiments with parameters \'{repr(params)}\'') - cb() - except docker.errors.APIError: - logger.error(f'Could not run container \'{container_name}\' or container was unexpectedly removed', exc_info=True) + logger.debug(f"Container '{container_name}' finished experiments for '{params.state}'") + Clients.push_results_to_all() + except docker.errors.ContainerError: + logger.error( + f"Could not run container '{container_name}' or container was unexpectedly removed", exc_info=True + ) finally: self.container_id_pool.put(container_id) thread = threading.Thread(target=start_container_thread) thread.start() - logger.info(f'Container \'{container_name}\' started experiments for \'{params.state}\'') + logger.info(f"Container '{container_name}' started experiments for '{params.state}'") # To avoid race-condition where more than max containers are started - time.sleep(5) + time.sleep(3) def get_nb_of_running_worker_containers(self): return len(self.get_runnning_containers()) - def get_runnning_containers(self): - return self.client.containers.list(filters={'label': 'bh_worker', 'status': 'running'}, ignore_removed=True) + @staticmethod + def get_runnning_containers(): + return docker.from_env().containers.list( + filters={'label': 'bh_worker', 'status': 'running'}, ignore_removed=True + ) def wait_until_all_evaluations_are_done(self): if self.max_nb_of_containers == 1: @@ -112,8 +118,7 @@ def wait_until_all_evaluations_are_done(self): break time.sleep(5) - def forcefully_stop_all_running_containers(self): - if self.max_nb_of_containers == 1: - return - for container in self.get_runnning_containers(): + @staticmethod + def forcefully_stop_all_running_containers(): + for container in WorkerManager.get_runnning_containers(): container.remove(force=True) diff --git a/bci/evaluations/collector.py b/bci/evaluations/collectors/collector.py similarity index 92% rename from bci/evaluations/collector.py rename to bci/evaluations/collectors/collector.py index c9e3278..e59f97f 100644 --- a/bci/evaluations/collector.py +++ b/bci/evaluations/collectors/collector.py @@ -1,11 +1,11 @@ +import logging from abc import abstractmethod from enum import Enum -import logging from bci.evaluations.collectors.base import BaseCollector -from .collectors.requests import RequestCollector -from .collectors.logs import LogCollector +from .logs import LogCollector +from .requests import RequestCollector logger = logging.getLogger(__name__) @@ -16,7 +16,6 @@ class Type(Enum): class Collector: - def __init__(self, types: list[Type]) -> None: self.collectors: list[BaseCollector] = [] if Type.REQUESTS in types: diff --git a/bci/evaluations/custom/custom_evaluation.py b/bci/evaluations/custom/custom_evaluation.py index e85237c..b359c03 100644 --- a/bci/evaluations/custom/custom_evaluation.py +++ b/bci/evaluations/custom/custom_evaluation.py @@ -1,23 +1,18 @@ import logging import os import textwrap -from unittest import TestResult from bci.browser.configuration.browser import Browser from bci.configuration import Global -from bci.evaluations.collector import Collector, Type -from bci.evaluations.custom.custom_mongodb import CustomMongoDB +from bci.evaluations.collectors.collector import Collector, Type from bci.evaluations.evaluation_framework import EvaluationFramework -from bci.evaluations.logic import TestParameters +from bci.evaluations.logic import TestParameters, TestResult from bci.web.clients import Clients logger = logging.getLogger(__name__) class CustomEvaluationFramework(EvaluationFramework): - - db_class = CustomMongoDB - def __init__(self): super().__init__() self.dir_tree = self.initialize_dir_tree() @@ -31,7 +26,8 @@ def path_to_dict(path): if os.path.isdir(path): return { sub_folder: path_to_dict(os.path.join(path, sub_folder)) - for sub_folder in os.listdir(path) if sub_folder != 'url_queue.txt' + for sub_folder in os.listdir(path) + if sub_folder != 'url_queue.txt' } else: return os.path.basename(path) @@ -47,27 +43,32 @@ def initialize_tests_and_url_queues(dir_tree: dict) -> dict: project_path = os.path.join(page_folder_path, project) experiments_per_project[project] = {} for experiment in experiments: - url_queue_file_path = os.path.join(project_path, experiment, 'url_queue.txt') - if os.path.isfile(url_queue_file_path): - # If an URL queue is specified, it is parsed and used - with open(url_queue_file_path) as file: - url_queue = file.readlines() - else: - # Otherwise, a default URL queue is used, based on the domain that hosts the main page - experiment_path = os.path.join(project_path, experiment) - for domain in os.listdir(experiment_path): - main_folder_path = os.path.join(experiment_path, domain, 'main') - if os.path.exists(main_folder_path): - url_queue = [ - f'https://{domain}/{project}/{experiment}/main', - 'https://a.test/report/?bughog_sanity_check=OK' - ] + url_queue = CustomEvaluationFramework.__get_url_queue(project, project_path, experiment) experiments_per_project[project][experiment] = { 'url_queue': url_queue, - 'runnable': CustomEvaluationFramework.is_runnable_experiment(project, experiment, dir_tree) + 'runnable': CustomEvaluationFramework.is_runnable_experiment(project, experiment, dir_tree), } return experiments_per_project + @staticmethod + def __get_url_queue(project: str, project_path: str, experiment: str) -> list[str]: + url_queue_file_path = os.path.join(project_path, experiment, 'url_queue.txt') + if os.path.isfile(url_queue_file_path): + # If an URL queue is specified, it is parsed and used + with open(url_queue_file_path) as file: + return file.readlines() + else: + # Otherwise, a default URL queue is used, based on the domain that hosts the main page + experiment_path = os.path.join(project_path, experiment) + for domain in os.listdir(experiment_path): + main_folder_path = os.path.join(experiment_path, domain, 'main') + if os.path.exists(main_folder_path): + return [ + f'https://{domain}/{project}/{experiment}/main', + 'https://a.test/report/?bughog_sanity_check=OK', + ] + raise AttributeError(f"Could not infer url queue for experiment '{experiment}' in project '{project}'") + @staticmethod def is_runnable_experiment(project: str, poc: str, dir_tree: dict) -> bool: domains = dir_tree[project][poc] @@ -98,17 +99,21 @@ def perform_specific_evaluation(self, browser: Browser, params: TestParameters) is_dirty = True finally: collector.stop() - data = collector.collect_results() + results = collector.collect_results() if not is_dirty: # New way to perform sanity check - if [var_entry for var_entry in data['req_vars'] if var_entry['var'] == 'sanity_check' and var_entry['val'] == 'OK']: + if [ + var_entry + for var_entry in results['req_vars'] + if var_entry['var'] == 'sanity_check' and var_entry['val'] == 'OK' + ]: pass # Old way for backwards compatibility - elif [request for request in data['requests'] if 'report/?leak=baseline' in request['url']]: + elif [request for request in results['requests'] if 'report/?leak=baseline' in request['url']]: pass else: is_dirty = True - return params.create_test_result_with(browser_version, binary_origin, data, is_dirty) + return params.create_test_result_with(browser_version, binary_origin, results, is_dirty) def get_mech_groups(self, project: str) -> list[tuple[str, bool]]: if project not in self.tests_per_project: @@ -122,14 +127,15 @@ def get_projects(self) -> list[str]: def get_poc_structure(self, project: str, poc: str) -> dict: return self.dir_tree[project][poc] - def get_poc_file(self, project: str, poc: str, domain: str, path: str, file: str) -> str: - file_path = os.path.join(Global.custom_page_folder, project, poc, domain, path, file) + def get_poc_file(self, project: str, poc: str, domain: str, path: str, file_name: str) -> str: + file_path = os.path.join(Global.custom_page_folder, project, poc, domain, path, file_name) if os.path.isfile(file_path): with open(file_path) as file: return file.read() + raise AttributeError(f"Could not find PoC file at expected path '{file_path}'") - def update_poc_file(self, project: str, poc: str, domain: str, path: str, file: str, content: str) -> bool: - file_path = os.path.join(Global.custom_page_folder, project, poc, domain, path, file) + def update_poc_file(self, project: str, poc: str, domain: str, path: str, file_name: str, content: str) -> bool: + file_path = os.path.join(Global.custom_page_folder, project, poc, domain, path, file_name) if os.path.isfile(file_path): if content == '': logger.warning('Attempt to save empty file ignored') @@ -164,15 +170,18 @@ def add_page(self, project: str, poc: str, domain: str, path: str, file_type: st headers_file_path = os.path.join(page_path, 'headers.json') if not os.path.exists(headers_file_path): with open(headers_file_path, 'w') as file: - file.write(textwrap.dedent( - '''\ + file.write( + textwrap.dedent( + """\ [ { "key": "Header-Name", "value": "Header-Value" } ] - ''')) + """ + ) + ) self.sync_with_folders() # Notify clients of change (an experiment might now be runnable) Clients.push_experiments_to_all() diff --git a/bci/evaluations/custom/custom_mongodb.py b/bci/evaluations/custom/custom_mongodb.py deleted file mode 100644 index b823efe..0000000 --- a/bci/evaluations/custom/custom_mongodb.py +++ /dev/null @@ -1,17 +0,0 @@ -from bci.database.mongo.mongodb import MongoDB - - -class CustomMongoDB(MongoDB): - - def __init__(self): - super().__init__() - self.data_collection_names = { - "chromium": "custom_chromium_data_test", - "firefox": "custom_firefox_release_data_test" - } - - def __get_data_collection(self, browser_name: str): - collection_name = self.data_collection_names[browser_name] - if collection_name not in self.db.collection_names(): - raise AttributeError("Collection '%s' not found in database" % collection_name) - return self.db[collection_name] diff --git a/bci/evaluations/evaluation_framework.py b/bci/evaluations/evaluation_framework.py index 018900c..10f0d1f 100644 --- a/bci/evaluations/evaluation_framework.py +++ b/bci/evaluations/evaluation_framework.py @@ -7,23 +7,22 @@ from bci.configuration import Global from bci.database.mongo.mongodb import MongoDB from bci.evaluations.logic import TestParameters, TestResult, WorkerParameters +from bci.version_control.states.state import StateCondition logger = logging.getLogger(__name__) class EvaluationFramework(ABC): - def __init__(self): self.should_stop = False def evaluate(self, worker_params: WorkerParameters): - test_params_list = worker_params.create_all_test_params() - test_params_list_to_evaluate: list[TestParameters] = list(filter( - lambda x: not self.has_result(x), - test_params_list - )) - logger.info(f'Pending tests for state {worker_params}: {len(test_params_list_to_evaluate)}/{len(test_params_list)}') - if len(test_params_list_to_evaluate) == 0: + test_params = worker_params.create_test_params() + + if MongoDB().has_result(test_params): + logger.warning( + f"Experiment '{test_params.mech_group}' for '{test_params.state}' was already performed, skipping." + ) return browser_config = worker_params.browser_configuration @@ -32,23 +31,20 @@ def evaluate(self, worker_params: WorkerParameters): browser = Browser.get_browser(browser_config, eval_config, state) browser.pre_evaluation_setup() - for test_params in test_params_list_to_evaluate: - if self.should_stop: - self.should_stop = False - return - try: - browser.pre_test_setup() - result = self.perform_specific_evaluation(browser, test_params) - - state.set_evaluation_outcome(result) - self.db_class.get_instance().store_result(result) - logger.info(f'Test finalized: {test_params}') - except Exception as e: - state.set_evaluation_error(str(e)) - logger.error("An error occurred during evaluation", exc_info=True) - traceback.print_exc() - finally: - browser.post_test_cleanup() + if self.should_stop: + self.should_stop = False + return + try: + browser.pre_test_setup() + result = self.perform_specific_evaluation(browser, test_params) + MongoDB().store_result(result) + logger.info(f'Test finalized: {test_params}') + except Exception as e: + state.condition = StateCondition.FAILED + logger.error('An error occurred during evaluation', exc_info=True) + traceback.print_exc() + finally: + browser.post_test_cleanup() browser.post_evaluation_cleanup() logger.debug('Evaluation finished') @@ -57,24 +53,6 @@ def evaluate(self, worker_params: WorkerParameters): def perform_specific_evaluation(self, browser: Browser, params: TestParameters) -> TestResult: pass - @property - @classmethod - @abstractmethod - def db_class(cls) -> MongoDB: - pass - - @classmethod - def has_result(cls: MongoDB, test_params: TestParameters) -> bool: - return cls.db_class.get_instance().has_result(test_params) - - @classmethod - def get_result(cls: MongoDB, test_params: TestParameters) -> TestResult: - return cls.db_class.get_instance().get_result(test_params) - - @classmethod - def has_all_results(cls: MongoDB, worker_params: WorkerParameters) -> bool: - return cls.db_class.get_instance().has_all_results(worker_params) - @staticmethod def get_extension_path(browser: str, extension_file: str): folder_path = Global.get_extension_folder(browser) @@ -87,7 +65,7 @@ def stop_gracefully(self): self.should_stop = True @abstractmethod - def get_mech_groups(self, project): + def get_mech_groups(self, project: str) -> list[tuple[str, bool]]: """ Returns the available mechanism groups for this evaluation framework. """ diff --git a/bci/evaluations/logic.py b/bci/evaluations/logic.py index 25d51f6..ad6f0e8 100644 --- a/bci/evaluations/logic.py +++ b/bci/evaluations/logic.py @@ -3,14 +3,15 @@ import json import logging from dataclasses import asdict, dataclass +from typing import Optional from werkzeug.datastructures import ImmutableMultiDict import bci.browser.cli_options.chromium as cli_options_chromium import bci.browser.cli_options.firefox as cli_options_firefox -from bci.version_control.states.state import State +from bci.version_control.states.state import State, StateResult -logger = logging.getLogger('bci') +logger = logging.getLogger(__name__) @dataclass(frozen=True) @@ -21,30 +22,25 @@ class EvaluationParameters: sequence_configuration: SequenceConfiguration database_collection: str - def create_worker_params_for(self, state: State, database_connection_params: DatabaseConnectionParameters) -> WorkerParameters: + def create_worker_params_for( + self, state: State, database_connection_params: DatabaseParameters) -> WorkerParameters: return WorkerParameters( self.browser_configuration, self.evaluation_configuration, state, - self.evaluation_range.mech_groups, + self.evaluation_range.mech_group, self.database_collection, database_connection_params ) - def create_test_for(self, state: State, mech_group: str) -> TestParameters: - assert mech_group in self.evaluation_range.mech_groups + def create_test_for(self, state: State) -> TestParameters: return TestParameters( - self.browser_configuration, - self.evaluation_configuration, - state, - mech_group, - self.database_collection + self.browser_configuration, self.evaluation_configuration, state, self.evaluation_range.mech_group, self.database_collection ) - def create_plot_params(self, mech_group: str, target_mech_id: str, dirty_allowed: bool = True) -> PlotParameters: - assert mech_group in self.evaluation_range.mech_groups + def create_plot_params(self, target_mech_id: str, dirty_allowed: bool = True) -> PlotParameters: return PlotParameters( - mech_group, + self.evaluation_range.mech_group, target_mech_id, self.browser_configuration.browser_name, self.database_collection, @@ -54,7 +50,7 @@ def create_plot_params(self, mech_group: str, target_mech_id: str, dirty_allowed self.browser_configuration.extensions, self.browser_configuration.cli_options, dirty_allowed, - self.sequence_configuration.target_cookie_name + self.sequence_configuration.target_cookie_name, ) @@ -71,10 +67,7 @@ def to_dict(self) -> dict: @staticmethod def from_dict(data: dict) -> BrowserConfiguration: return BrowserConfiguration( - data['browser_name'], - data['browser_setting'], - data['cli_options'], - data['extensions'] + data['browser_name'], data['browser_setting'], data['cli_options'], data['extensions'] ) @@ -89,18 +82,14 @@ def to_dict(self) -> dict: @staticmethod def from_dict(data: dict) -> EvaluationConfiguration: - return EvaluationConfiguration( - data['project'], - data['automation'], - data['seconds_per_visit'] - ) + return EvaluationConfiguration(data['project'], data['automation'], data['seconds_per_visit']) @dataclass(frozen=True) class EvaluationRange: - mech_groups: list[str] - major_version_range: tuple[int] | None = None - revision_number_range: tuple[int] | None = None + mech_group: str + major_version_range: tuple[int, int] | None = None + revision_number_range: tuple[int, int] | None = None only_release_revisions: bool = False def __post_init__(self): @@ -122,23 +111,19 @@ class SequenceConfiguration: @dataclass(frozen=True) -class DatabaseConnectionParameters: +class DatabaseParameters: host: str username: str password: str database_name: str + binary_cache_limit: int def to_dict(self) -> dict: return asdict(self) @staticmethod - def from_dict(data: dict) -> DatabaseConnectionParameters: - return DatabaseConnectionParameters( - data['host'], - data['username'], - data['password'], - data['database_name'] - ) + def from_dict(data: dict) -> DatabaseParameters: + return DatabaseParameters(data['host'], data['username'], data['password'], data['database_name'], data['binary_cache_limit']) def __str__(self) -> str: return f'{self.username}@{self.host}:27017/{self.database_name}' @@ -149,36 +134,21 @@ class WorkerParameters: browser_configuration: BrowserConfiguration evaluation_configuration: EvaluationConfiguration state: State - mech_groups: list[str] + mech_group: str database_collection: str - database_connection_params: DatabaseConnectionParameters + database_connection_params: DatabaseParameters - def create_test_params_for(self, mech_group: str) -> TestParameters: - assert mech_group in self.mech_groups + def create_test_params(self) -> TestParameters: return TestParameters( - self.browser_configuration, - self.evaluation_configuration, - self.state, - mech_group, - self.database_collection + self.browser_configuration, self.evaluation_configuration, self.state, self.mech_group, self.database_collection ) - def create_all_test_params(self) -> list[TestParameters]: - return [ - TestParameters( - self.browser_configuration, - self.evaluation_configuration, - self.state, - mech_group, - self.database_collection) - for mech_group in self.mech_groups] - def _to_dict(self): return { 'browser_configuration': self.browser_configuration.to_dict(), 'evaluation_configuration': self.evaluation_configuration.to_dict(), 'state': self.state.to_dict(), - 'mech_groups': self.mech_groups, + 'mech_group': self.mech_group, 'database_collection': self.database_collection, 'database_connection_params': self.database_connection_params.to_dict() } @@ -198,20 +168,15 @@ def deserialize(string: str) -> WorkerParameters: browser_config = BrowserConfiguration.from_dict(data['browser_configuration']) eval_config = EvaluationConfiguration.from_dict(data['evaluation_configuration']) state = State.from_dict(data['state']) - mech_groups = data['mech_groups'] + mech_group = data['mech_group'] database_collection = data['database_collection'] - database_connection_params = DatabaseConnectionParameters.from_dict(data['database_connection_params']) + database_connection_params = DatabaseParameters.from_dict(data['database_connection_params']) return WorkerParameters( - browser_config, - eval_config, - state, - mech_groups, - database_collection, - database_connection_params + browser_config, eval_config, state, mech_group, database_collection, database_connection_params ) def __str__(self) -> str: - return f'Eval({self.state}: [{", ".join(self.mech_groups)}])' + return f'Eval({self.state}: [{", ".join(self.mech_group)}])' @dataclass(frozen=True) @@ -223,13 +188,7 @@ class TestParameters: database_collection: str def create_test_result_with(self, browser_version: str, binary_origin: str, data: dict, dirty: bool) -> TestResult: - return TestResult( - self, - browser_version, - binary_origin, - data, - dirty - ) + return TestResult(self, browser_version, binary_origin, data, dirty) @dataclass(frozen=True) @@ -245,18 +204,14 @@ class TestResult: def padded_browser_version(self): padding_target = 4 padded_version = [] - for sub in self.browser_version.split("."): + for sub in self.browser_version.split('.'): if len(sub) > padding_target: - raise AttributeError(f'Version \'{self.browser_version}\' is too big to be padded') + raise AttributeError(f"Version '{self.browser_version}' is too big to be padded") padded_version.append('0' * (padding_target - len(sub)) + sub) - return ".".join(padded_version) + return '.'.join(padded_version) - @property - def reproduced(self): - entry_if_reproduced = {'var': 'reproduced', 'val': 'OK'} - reproduced_in_req_vars = [entry for entry in self.data['req_vars'] if entry == entry_if_reproduced] != [] - reproduced_in_log_vars = [entry for entry in self.data['log_vars'] if entry == entry_if_reproduced] != [] - return reproduced_in_req_vars or reproduced_in_log_vars + def get_state_result(self) -> StateResult: + return StateResult.from_dict(self.data, self.is_dirty) @dataclass(frozen=True) @@ -265,59 +220,53 @@ class PlotParameters: target_mech_id: str browser_name: str database_collection: str - major_version_range: tuple[int] = None - revision_number_range: tuple[int] = None + major_version_range: Optional[tuple[int,int]] = None + revision_number_range: Optional[tuple[int,int]] = None browser_config: str = 'default' - extensions: list[str] = None - cli_options: list[str] = None + extensions: Optional[list[str]] = None + cli_options: Optional[list[str]] = None dirty_allowed: bool = True - target_cookie_name: str = None + target_cookie_name: Optional[str] = None @staticmethod -def evaluation_factory(kwargs: ImmutableMultiDict) -> EvaluationParameters: +def evaluation_factory(kwargs: ImmutableMultiDict) -> list[EvaluationParameters]: browser_configuration = BrowserConfiguration( - kwargs.get('browser_name'), - kwargs.get('browser_setting'), - __get_cli_arguments(kwargs), - __get_extensions(kwargs) + kwargs.get('browser_name'), kwargs.get('browser_setting'), __get_cli_arguments(kwargs), __get_extensions(kwargs) ) evaluation_configuration = EvaluationConfiguration( - kwargs.get('project'), - kwargs.get('automation'), - int(kwargs.get('seconds_per_visit', 5)) - ) - evaluation_range = EvaluationRange( - kwargs.get('tests', []), - __get_version_range(kwargs), - __get_revision_number_range(kwargs), - kwargs.get('only_release_revisions', False) + kwargs.get('project'), kwargs.get('automation'), int(kwargs.get('seconds_per_visit', 5)) ) sequence_configuration = SequenceConfiguration( int(kwargs.get('nb_of_containers')), int(kwargs.get('sequence_limit')), kwargs.get('target_mech_id', None), __get_cookie_name(kwargs), - kwargs.get('search_strategy') - ) - database_collection = kwargs.get('db_collection') - evaluation_params = EvaluationParameters( - browser_configuration, - evaluation_configuration, - evaluation_range, - sequence_configuration, - database_collection + kwargs.get('search_strategy'), ) - return evaluation_params + evaluation_params_list = [] + for mech_group in kwargs.get('tests', []): + evaluation_range = EvaluationRange( + mech_group, + __get_version_range(kwargs), + __get_revision_number_range(kwargs), + kwargs.get('only_release_revisions', False), + ) + database_collection = kwargs.get('db_collection') + evaluation_params = EvaluationParameters( + browser_configuration, evaluation_configuration, evaluation_range, sequence_configuration, database_collection + ) + evaluation_params_list.append(evaluation_params) + return evaluation_params_list @staticmethod def __get_cookie_name(form_data: dict[str, str]) -> str | None: - if form_data["check_for"] == "request": + if form_data['check_for'] == 'request': return None - if "cookie_name" in form_data: - return form_data["cookie_name"] - return "generic" + if 'cookie_name' in form_data: + return form_data['cookie_name'] + return 'generic' @staticmethod @@ -344,11 +293,11 @@ def __get_revision_number_range(form_data: dict[str, str]) -> tuple[int, int] | def __get_extensions(form_data: dict[str, str]) -> list[str]: return list( map( - lambda x: x.replace("ext_", ""), + lambda x: x.replace('ext_', ''), filter( - lambda x: x.startswith("ext_") and form_data[x] == "true", + lambda x: x.startswith('ext_') and form_data[x] == 'true', form_data.keys(), - ) + ), ) ) @@ -362,5 +311,5 @@ def __get_cli_arguments(form_data: dict[str, str]) -> list[str]: case 'firefox': available_cli_options = cli_options_firefox.get_all_cli_options() case _: - raise AttributeError(f'Unknown browser \'{browser}\'') + raise AttributeError(f"Unknown browser '{browser}'") return list(filter(lambda x: x in form_data, available_cli_options)) diff --git a/bci/evaluations/outcome_checker.py b/bci/evaluations/outcome_checker.py index 54c93db..98fc859 100644 --- a/bci/evaluations/outcome_checker.py +++ b/bci/evaluations/outcome_checker.py @@ -1,7 +1,7 @@ import re -from abc import abstractmethod -from bci.evaluations.logic import SequenceConfiguration, TestResult +from bci.evaluations.logic import SequenceConfiguration +from bci.version_control.states.state import StateResult class OutcomeChecker: @@ -9,8 +9,7 @@ class OutcomeChecker: def __init__(self, sequence_config: SequenceConfiguration): self.sequence_config = sequence_config - @abstractmethod - def get_outcome(self, result: TestResult) -> bool: + def get_outcome(self, result: StateResult) -> bool | None: ''' Returns the outcome of the test result. @@ -24,12 +23,12 @@ def get_outcome(self, result: TestResult) -> bool: return True # Backwards compatibility if self.sequence_config.target_mech_id: - return self.get_outcome_for_proxy(result) + return self.__get_outcome_for_proxy(result) - def get_outcome_for_proxy(self, result: TestResult) -> bool | None: + def __get_outcome_for_proxy(self, result: StateResult) -> bool | None: target_mech_id = self.sequence_config.target_mech_id target_cookie = self.sequence_config.target_cookie_name - requests = result.data.get('requests') + requests = result.requests if requests is None: return None # DISCLAIMER: diff --git a/bci/evaluations/samesite/__init__.py b/bci/evaluations/samesite/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/bci/evaluations/samesite/samesite_evaluation.py b/bci/evaluations/samesite/samesite_evaluation.py deleted file mode 100644 index d9f02c9..0000000 --- a/bci/evaluations/samesite/samesite_evaluation.py +++ /dev/null @@ -1,74 +0,0 @@ -import os -import shutil -from bci.evaluations.evaluation_framework import EvaluationFramework -from bci.evaluations.samesite.samesite_mongodb import SamesiteMongoDB -from bci.version_control.states.state import State - -all_mech_groups = [ - "appcache", - "header-csp", - "header-link", - "pdf", - "redirect", - "script", - "static", - "sw", -] - - -class SameSiteEvaluationFramework(EvaluationFramework): - - db_class = SamesiteMongoDB - - def perform_specific_evaluation( - self, - automation: str, - browser: str, - browser_version: str, - driver_version: str, - browser_config: str, - extension_name: str, - additional_cli_options: list, - mech_id: str, - mech_group: str, - browser_binary: str, - driver_exec: str, - state: State, - cookie_name: str): - if not self.has_result(automation, browser, browser_config, extension_name, additional_cli_options, mech_group, state): - data_folder = self.get_data_path(browser, state, browser_config) - extension_path = self.get_extension_path(browser, extension_name) if extension_name else None - - self.logger.info(f"Starting browser evaluation for {browser} v{browser_version} with driver {driver_exec}") - Jar.do_automation(automation, browser, browser_version, browser_config, extension_path, - browser_binary, additional_cli_options, driver_exec, data_folder, mech_group) - - json_data = self.get_data_in_json(data_folder, mech_group) - is_dirty = self.is_dirty_evaluation(data_folder, mech_group) - self.db_class.get_instance().store_data(automation, browser, browser_version, driver_version, browser_config, - extension_name, additional_cli_options, state, mech_group, - json_data, is_dirty) - - # Remove csv files - try: - shutil.rmtree(os.path.dirname(data_folder)) - except OSError: - self.logger.error("Could not remove temporary data folder", exc_info=True) - - return self.get_result(automation, browser, browser_config, extension_name, - additional_cli_options, mech_group, mech_id, state, cookie_name) - - def get_data_in_json(self, data_path, mech_group) -> dict: - data_file_path = os.path.join(data_path, "%s.csv" % mech_group) - return self.read_csv_file(data_file_path) - - @staticmethod - def is_dirty_evaluation(data_path, mech_group): - """ - Returns True if an exception was thrown during the evaluation, otherwise returns False. - """ - data_exception_file_path = os.path.join(data_path, "%s_EXCEPTION.csv" % mech_group) - return os.path.isfile(data_exception_file_path) - - def get_mech_groups(self): - return all_mech_groups diff --git a/bci/evaluations/samesite/samesite_mongodb.py b/bci/evaluations/samesite/samesite_mongodb.py deleted file mode 100644 index 8e2768a..0000000 --- a/bci/evaluations/samesite/samesite_mongodb.py +++ /dev/null @@ -1,17 +0,0 @@ -from bci.database.mongo.mongodb import MongoDB - - -class SamesiteMongoDB(MongoDB): - - def __init__(self): - super().__init__() - self.data_collection_names = { - "chromium": "chromium_data", - "firefox": "firefox_data" - } - - def __get_data_collection(self, browser_name: str): - collection_name = self.data_collection_names[browser_name] - if collection_name not in self.db.collection_names(): - raise AttributeError("Collection '%s' not found in database" % collection_name) - return self.db[collection_name] diff --git a/bci/evaluations/xsleaks/__init__.py b/bci/evaluations/xsleaks/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/bci/evaluations/xsleaks/evaluation.py b/bci/evaluations/xsleaks/evaluation.py deleted file mode 100644 index 2ac4f48..0000000 --- a/bci/evaluations/xsleaks/evaluation.py +++ /dev/null @@ -1,50 +0,0 @@ -import time - -from bci.evaluations.evaluation_framework import EvaluationFramework -from bci.evaluations.xsleaks.mongodb import XSLeaksMonogDB -from bci.evaluations.xsleaks.testcase.first import First, TestCase -from bci.evaluations.xsleaks.testcase import cases -from bci.version_control.states.state import State - - -all_mech_groups = [cls.__name__ for cls in TestCase.__subclasses__()] - - -class XSLeaksEvaluation(EvaluationFramework): - - db_class = XSLeaksMonogDB - - @staticmethod - def get_case_object(browser: str, browser_version: str, mech_id: str, mech_group: str, - browser_binary: str, state: State) -> TestCase: - case_class = getattr(cases, mech_group) - return case_class(browser, browser_version, mech_id, mech_group, browser_binary, state) - - def perform_specific_evaluation(self, automation: str, browser: str, browser_version: str, driver_version: str, - browser_config: str, extension_file: str, additional_cli_options: list, - mech_id: str, mech_group: str, browser_binary: str, driver_exec: str, - state: State, cookie_name: str): - if mech_group == "first": - test = First(browser, browser_version, mech_id, browser_binary, state) - else: - test = self.get_case_object(browser, browser_version, mech_id, mech_group, browser_binary, state) - if test is None: - raise AttributeError("Unknown test '%s'" % mech_group) - - report = test.run() - time.sleep(5) - mongodb = XSLeaksMonogDB.get_instance() - - is_dirty = self.is_dirty_evaluation(report) - mongodb.store_data(automation, browser, browser_version, driver_version, browser_config, - None, additional_cli_options, state, mech_group, report, is_dirty) - - def get_data_in_json(self, data_path, mech_group): - pass - - @staticmethod - def is_dirty_evaluation(report): - return report is None - - def get_mech_groups(self): - return all_mech_groups diff --git a/bci/evaluations/xsleaks/mongodb.py b/bci/evaluations/xsleaks/mongodb.py deleted file mode 100644 index 3fcabd0..0000000 --- a/bci/evaluations/xsleaks/mongodb.py +++ /dev/null @@ -1,24 +0,0 @@ -import logging -from bci.database.mongo.mongodb import MongoDB - - -class XSLeaksMonogDB(MongoDB): - - def __init__(self): - super().__init__() - self.data_collection_names = { - "chromium": "chromium_xsleaks_data", - "firefox": "firefox_xsleaks_data" - } - - @staticmethod - def get_instance(): - if XSLeaksMonogDB.instance is None: - XSLeaksMonogDB.instance = XSLeaksMonogDB() - return XSLeaksMonogDB.instance - - def __get_data_collection(self, browser_name: str): - collection_name = self.data_collection_names[browser_name] - if collection_name not in self.db.collection_names(): - raise AttributeError("Collection '%s' not found in database" % collection_name) - return self.db[collection_name] diff --git a/bci/evaluations/xsleaks/testcase/__init__.py b/bci/evaluations/xsleaks/testcase/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/bci/evaluations/xsleaks/testcase/cases.py b/bci/evaluations/xsleaks/testcase/cases.py deleted file mode 100644 index c8e20fe..0000000 --- a/bci/evaluations/xsleaks/testcase/cases.py +++ /dev/null @@ -1,225 +0,0 @@ -from bci.evaluations.xsleaks.testcase.testcase import TestCase - - -class Case01(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case1/main/") - report = self.read_report("case1.json") - return report - - -class Case02(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case2/main/") - report = self.read_report("case2.json") - return report - - -class Case03(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case3/main/") - report = self.read_report("case3.json") - return report - - -class Case04(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case4/main/", sleep_after_visit=15) - report = self.read_report("case4.json") - return report - - -class Case05(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case5/main/") - report = self.read_report("case5.json") - return report - - -class Case06(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case6/a/", sleep_after_visit=20) - report = self.read_report("case6.json") - return report - - -class Case07(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case7/main/") - report = self.read_report("case7.json") - return report - - -class Case08(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case8/main/") - report = self.read_report("case8.json") - return report - - -class Case09(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case9/main/") - report = self.read_report("case9.json") - return report - - -class Case10(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case10/main/") - report = self.read_report("case10.json") - return report - - -class Case11(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case11/redirect/") - self.visit("https://attack.er/custom/case11/no_redirect/") - report1 = self.read_report("case11_redirect.json") - report2 = self.read_report("case11_no_redirect.json") - - if report1 and report2: - report = {**report1, **report2} - elif report1: - report = report1 - report["no_redirect"] = None - else: - report = report2 - report["redirect"] = None - report["xsleak_reproduced"] = report["no_redirect"] and report["redirect"] - return report - - -class Case12(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case12/main/") - report = self.read_report("case12.json") - return report - - -class Case13(TestCase): - - def run(self): - self.visit("https://sub.leak.test/resource1") - self.visit("https://attack.er/custom/case13/main/", clean_profile=False) - report = self.read_report("case13.json") - return report - - -class Case15(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case15/main/") - report = self.read_report("case15.json") - return report - - -class Case18(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case18/main/") - report = self.read_report("case18.json") - return report - - -class Case19(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case19/main/") - report = self.read_report("case19.json") - return report - - -class Case20(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case20/main/") - report = self.read_report("case20.json") - return report - - -class Case25(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case25/main/") - report = self.read_report("case25.json") - return report - - -class Case25b(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case25/old/") - report = self.read_report("case25b.json") - return report - - -class Case29(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case29/main/") - report = self.read_report("case29.json") - return report - - -class Case30(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case30/main/") - report = self.read_report("case30.json") - return report - - -class Case31(TestCase): - - def run(self): - self.visit("https://attack.er/custom/case31/main/") - report = self.read_report("case31.json") - return report - - -class DefaultSameSite(TestCase): - - def run(self): - self.visit("https://re.port/set_cookie/generic_cookie/1/") - self.visit("https://attack.er/custom/default_samesite/main", clean_profile=False) - report = self.read_report("default_samesite.json") - return report - - -class SameSite(TestCase): - - def run(self): - self.visit("https://re.port/set_lax_cookie/ss_lax_cookie/1/") - self.visit("https://attack.er/custom/samesite/main", clean_profile=False) - report = self.read_report("samesite.json") - return report - - -class SecFetchSite(TestCase): - - def run(self): - self.visit("https://attack.er/custom/SecFetchSite/main") - report = self.read_report("SecFetchSite.json") - return report - - -class NetworkIsolation(TestCase): - - def run(self): - self.visit("https://leak.test/custom/NetworkIsolation/main") - self.visit("https://attack.er/custom/NetworkIsolation/main", clean_profile=False) - report = self.read_report("NetworkIsolation.json") - return report diff --git a/bci/evaluations/xsleaks/testcase/first.py b/bci/evaluations/xsleaks/testcase/first.py deleted file mode 100644 index c84bfa5..0000000 --- a/bci/evaluations/xsleaks/testcase/first.py +++ /dev/null @@ -1,13 +0,0 @@ -from bci.evaluations.xsleaks.testcase.testcase import TestCase - - -class First(TestCase): - - def run(self): - self.visit("https://leak.test/custom/test/main") - self.visit("https://leak.test/custom/test/main") - self.visit("https://leak.test/custom/test/main") - self.visit("https://leak.test/custom/test/main") - self.visit("https://leak.test/custom/test/main") - report = self.read_report("test.json") - return report diff --git a/bci/evaluations/xsleaks/testcase/testcase.py b/bci/evaluations/xsleaks/testcase/testcase.py deleted file mode 100644 index d4294ac..0000000 --- a/bci/evaluations/xsleaks/testcase/testcase.py +++ /dev/null @@ -1,95 +0,0 @@ -import json -import time -import logging -import os.path -from abc import abstractmethod -from bci import cli -from bci import util -from bci.version_control.states.state import State - - -class TestCase: - - REPORTS_FOLDER = "/reports" - - def __init__(self, browser: str, browser_version: str, mech_id: str, browser_binary: str, - state: State): - self.logger = logging.getLogger("bci") - self.browser = browser - self.browser_version = browser_version - self.mech_id = mech_id - self.browser_binary = browser_binary - self.state = state - self.profile_path: str = "" - - @abstractmethod - def run(self): - pass - - def get_new_profile(self): - if self.profile_path is not None: - util.rmtree(self.profile_path) - self.profile_path = Jar.increment_until_original("/tmp/new-profile") - cli.execute("mkdir -p %s" % self.profile_path) - if self.browser == "chromium": - pass - elif self.browser == "firefox": - # Make Firefox trust the proxy CA and server CA - # cert9.db key4.db pkcs11.txt - cli.execute( - "certutil -A -n littleproxy -t CT,c -i /app/ssl/LittleProxy_MITM.cer -d sql:%s" % self.profile_path) - # Normally: cert8.db key3.db secmod.db, however: cert9.db key4.db pkcs11.txt - cli.execute( - "certutil -A -n littleproxy -t CT,c -i /app/ssl/LittleProxy_MITM.cer -d %s" % self.profile_path) - # cert9.db key4.db pkcs11.txt - cli.execute( - "certutil -A -n myCA -t CT,c -i /app/ssl/myCA.crt -d sql:%s" % self.profile_path) - # Normally: cert8.db key3.db secmod.db, however: cert9.db key4.db pkcs11.txt - cli.execute( - "certutil -A -n myCA -t CT,c -i /app/ssl/myCA.crt -d %s" % self.profile_path) - # The certutil in the docker image refuses to create cert8.db, so we copy - # an existing cert8.db which accepts the necessary CAs - cli.execute("cp /app/browser/profiles/firefox/cert8.db %s" % self.profile_path) - - def visit(self, url: str, clean_profile=True, sleep_after_visit=20): - self.logger.info("Visiting '%s' %s a clean profile" % (url, "with" if clean_profile or self.profile_path is None else "without")) - if self.profile_path == "" or clean_profile: - self.get_new_profile() - if self.browser == "chromium": - command = "%s --no-sandbox --disable-component-update --disable-popup-blocking --ignore-certificate-errors \ - --enable-logging --v=1 --user-data-dir=%s %s" % (self.browser_binary, self.profile_path, url) - elif self.browser == "firefox": - prefs_path = os.path.join(self.profile_path, "prefs.js") - with open(prefs_path, "w") as file: - file.write('user_pref("app.update.enabled", false);\n') - file.write('user_pref("dom.disable_open_during_load", false);\n') - command = "%s -profile %s %s" % (self.browser_binary, self.profile_path, url) - else: - raise AttributeError("Unknown browser '%s'" % self.browser) - self.logger.info("Executing command '%s'" % command) - cli.execute_as_daemon(command) - - time.sleep(sleep_after_visit) - command = "pkill -SIGINT %s" % ("chrome" if self.browser == "chromium" else "firefox") - self.logger.info("Executing command '%s'" % command) - cli.execute_and_return_status(command) - time.sleep(3) - command = "pkill -SIGINT %s" % ("chrome" if self.browser == "chromium" else "firefox") - self.logger.info("Executing command '%s'" % command) - cli.execute_and_return_status(command) - time.sleep(3) - command = "pkill -o dbus-launch" - self.logger.info("Executing command '%s'" % command) - cli.execute_and_return_status(command) - time.sleep(1) - - @staticmethod - def read_report(file_name: str, remove_after=True): - path = os.path.join(TestCase.REPORTS_FOLDER, file_name) - if not os.path.isfile(path): - return None - with open(path, "r") as file: - report = json.load(file) - if remove_after: - cli.execute("rm %s" % path) - return report diff --git a/bci/main.py b/bci/main.py index 41d30a1..127c1c2 100644 --- a/bci/main.py +++ b/bci/main.py @@ -61,7 +61,7 @@ def format_to_user_log(log: dict) -> str: @staticmethod def get_database_info() -> dict: - return MongoDB.get_info() + return MongoDB().get_info() @staticmethod def get_browser_support() -> list[dict]: @@ -85,13 +85,11 @@ def download_online_binary(browser, rev_number): @staticmethod def get_mech_groups_of_evaluation_framework(evaluation_name: str, project) -> list[tuple[str, bool]]: - return Main.master.get_specific_evaluation_framework( - evaluation_name - ).get_mech_groups(project) + return Main.master.evaluation_framework.get_mech_groups(project) @staticmethod def get_projects_of_custom_framework() -> list[str]: - return Main.master.available_evaluation_frameworks["custom"].get_projects() + return Main.master.evaluation_framework.get_projects() @staticmethod def convert_to_plotparams(data: dict) -> PlotParameters: @@ -130,25 +128,25 @@ def get_data_sources(data: dict): return None, None return \ - PlotFactory.get_plot_revision_data(params, MongoDB.get_instance()), \ - PlotFactory.get_plot_version_data(params, MongoDB.get_instance()) + PlotFactory.get_plot_revision_data(params, MongoDB()), \ + PlotFactory.get_plot_version_data(params, MongoDB()) @staticmethod def get_poc(project: str, poc: str) -> dict: - return Main.master.get_specific_evaluation_framework("custom").get_poc_structure(project, poc) + return Main.master.evaluation_framework.get_poc_structure(project, poc) @staticmethod def get_poc_file(project: str, poc: str, domain: str, path: str, file: str) -> str: - return Main.master.get_specific_evaluation_framework("custom").get_poc_file(project, poc, domain, path, file) + return Main.master.evaluation_framework.get_poc_file(project, poc, domain, path, file) @staticmethod def update_poc_file(project: str, poc: str, domain: str, path: str, file: str, content: str) -> bool: logger.debug(f'Updating file {file} of project {project} and poc {poc}') - return Main.master.get_specific_evaluation_framework("custom").update_poc_file(project, poc, domain, path, file, content) + return Main.master.evaluation_framework.update_poc_file(project, poc, domain, path, file, content) @staticmethod def create_empty_poc(project: str, poc_name: str) -> bool: - return Main.master.get_specific_evaluation_framework("custom").create_empty_poc(project, poc_name) + return Main.master.evaluation_framework.create_empty_poc(project, poc_name) @staticmethod def get_available_domains() -> list[str]: @@ -156,7 +154,7 @@ def get_available_domains() -> list[str]: @staticmethod def add_page(project: str, poc: str, domain: str, path: str, file_type: str) -> bool: - return Main.master.get_specific_evaluation_framework("custom").add_page(project, poc, domain, path, file_type) + return Main.master.evaluation_framework.add_page(project, poc, domain, path, file_type) @staticmethod def sigint_handler(signum, frame): diff --git a/bci/master.py b/bci/master.py index 2f12751..60b36ce 100644 --- a/bci/master.py +++ b/bci/master.py @@ -3,206 +3,167 @@ import bci.database.mongo.container as mongodb_container from bci.configuration import Global from bci.database.mongo.mongodb import MongoDB, ServerException +from bci.database.mongo.revision_cache import RevisionCache from bci.distribution.worker_manager import WorkerManager from bci.evaluations.custom.custom_evaluation import CustomEvaluationFramework -from bci.evaluations.evaluation_framework import EvaluationFramework from bci.evaluations.logic import ( - DatabaseConnectionParameters, + DatabaseParameters, EvaluationParameters, - SequenceConfiguration, - WorkerParameters, ) from bci.evaluations.outcome_checker import OutcomeChecker -from bci.evaluations.samesite.samesite_evaluation import SameSiteEvaluationFramework -from bci.evaluations.xsleaks.evaluation import XSLeaksEvaluation +from bci.search_strategy.bgb_search import BiggestGapBisectionSearch +from bci.search_strategy.bgb_sequence import BiggestGapBisectionSequence from bci.search_strategy.composite_search import CompositeSearch -from bci.search_strategy.n_ary_search import NArySearch -from bci.search_strategy.n_ary_sequence import NArySequence, SequenceFinished -from bci.search_strategy.sequence_strategy import SequenceStrategy -from bci.version_control import factory -from bci.version_control.states.state import State +from bci.search_strategy.sequence_strategy import SequenceFinished, SequenceStrategy +from bci.version_control.factory import StateFactory +from bci.version_control.states.revisions.firefox import BINARY_AVAILABILITY_MAPPING from bci.web.clients import Clients logger = logging.getLogger(__name__) class Master: - - def __init__(self): - self.state = { - 'is_running': False, - 'reason': 'init', - 'status': 'idle' - } + def __init__(self) -> None: + self.state = {'is_running': False, 'reason': 'init', 'status': 'idle'} self.stop_gracefully = False self.stop_forcefully = False - # self.evaluations = [] - self.evaluation_framework = None - self.worker_manager = None - self.available_evaluation_frameworks = {} - self.firefox_build = None self.chromium_build = None + self.eval_queue = [] + Global.initialize_folders() - self.db_connection_params = Global.get_database_connection_params() + self.db_connection_params = Global.get_database_params() self.connect_to_database(self.db_connection_params) - self.inititialize_available_evaluation_frameworks() - logger.info("BugHog is ready!") + RevisionCache.store_firefox_binary_availability(BINARY_AVAILABILITY_MAPPING) # TODO: find better place + self.evaluation_framework = CustomEvaluationFramework() + logger.info('BugHog is ready!') - def connect_to_database(self, db_connection_params: DatabaseConnectionParameters): + def connect_to_database(self, db_connection_params: DatabaseParameters) -> None: try: - MongoDB.connect(db_connection_params) + MongoDB().connect(db_connection_params) except ServerException: - logger.error("Could not connect to database.", exc_info=True) - - def run(self, eval_params: EvaluationParameters): - self.state = { - 'is_running': True, - 'reason': 'user', - 'status': 'running' - } + logger.error('Could not connect to database.', exc_info=True) + + def run(self, eval_params_list: list[EvaluationParameters]) -> None: + # Sequence_configuration settings are the same over evaluation parameters (quick fix) + worker_manager = WorkerManager(eval_params_list[0].sequence_configuration.nb_of_containers) self.stop_gracefully = False self.stop_forcefully = False - - Clients.push_info_to_all('is_running', 'state') - - browser_config = eval_params.browser_configuration - evaluation_config = eval_params.evaluation_configuration - evaluation_range = eval_params.evaluation_range - sequence_config = eval_params.sequence_configuration - - logger.info(f'Running experiments for {browser_config.browser_name} ({", ".join(evaluation_range.mech_groups)})') - self.evaluation_framework = self.get_specific_evaluation_framework( - evaluation_config.project - ) - self.worker_manager = WorkerManager(sequence_config.nb_of_containers) - try: - state_list = factory.create_state_collection(browser_config, evaluation_range) - - search_strategy = self.parse_search_strategy(sequence_config, state_list) - - outcome_checker = OutcomeChecker(sequence_config) - - # The state_lineage is put into self.evaluation as a means to check on the process through front-end - # self.evaluations.append(state_list) - - try: - current_state = search_strategy.next() - while (self.stop_gracefully or self.stop_forcefully) is False: - worker_params = eval_params.create_worker_params_for(current_state, self.db_connection_params) - - # Callback function for sequence strategy - update_outcome = self.get_update_outcome_cb(search_strategy, worker_params, sequence_config, outcome_checker) - - # Check whether state is already evaluated - if self.evaluation_framework.has_all_results(worker_params): - logger.info(f"'{current_state}' already evaluated.") - update_outcome() - current_state = search_strategy.next() - continue - - # Start worker to perform evaluation - self.worker_manager.start_test(worker_params, update_outcome) - - current_state = search_strategy.next() - except SequenceFinished: - logger.debug("Last experiment has started") - self.state['reason'] = 'finished' + self.__init_eval_queue(eval_params_list) + for eval_params in eval_params_list: + if self.stop_gracefully or self.stop_forcefully: + break + self.__update_eval_queue(eval_params.evaluation_range.mech_group, 'active') + self.__update_state(is_running=True,reason='user', status='running', queue=self.eval_queue) + self.run_single_evaluation(eval_params, worker_manager) except Exception as e: - logger.critical("A critical error occurred", exc_info=True) + logger.critical('A critical error occurred', exc_info=True) raise e finally: # Gracefully exit if self.stop_gracefully: - logger.info("Gracefully stopping experiment queue due to user end signal...") + logger.info('Gracefully stopping experiment queue due to user end signal...') self.state['reason'] = 'user' if self.stop_forcefully: - logger.info("Forcefully stopping experiment queue due to user end signal...") + logger.info('Forcefully stopping experiment queue due to user end signal...') self.state['reason'] = 'user' - self.worker_manager.forcefully_stop_all_running_containers() + worker_manager.forcefully_stop_all_running_containers() else: - logger.info("Gracefully stopping experiment queue since last experiment started.") + logger.info('Gracefully stopping experiment queue since last experiment started.') # MongoDB.disconnect() - logger.info("Waiting for remaining experiments to stop...") - self.worker_manager.wait_until_all_evaluations_are_done() - logger.info("BugHog has finished the evaluation!") - self.state['is_running'] = False - self.state['status'] = 'idle' - Clients.push_info_to_all('is_running', 'state') + logger.info('Waiting for remaining experiments to stop...') + worker_manager.wait_until_all_evaluations_are_done() + logger.info('BugHog has finished the evaluation!') + self.__update_state(is_running=False, status='idle', queue=self.eval_queue) - @staticmethod - def get_update_outcome_cb(search_strategy: SequenceStrategy, worker_params: WorkerParameters, sequence_config: SequenceConfiguration, checker: OutcomeChecker) -> None: - def cb(): - if sequence_config.target_mech_id is not None and len(worker_params.mech_groups) == 1: - result = MongoDB.get_instance().get_result(worker_params.create_test_params_for(worker_params.mech_groups[0])) - outcome = checker.get_outcome(result) - search_strategy.update_outcome(worker_params.state, outcome) - # Just push results update to all clients. Could be more efficient, but would complicate things... - Clients.push_results_to_all() - return cb - - def inititialize_available_evaluation_frameworks(self): - self.available_evaluation_frameworks["samesite"] = SameSiteEvaluationFramework() - self.available_evaluation_frameworks["custom"] = CustomEvaluationFramework() - self.available_evaluation_frameworks["xsleaks"] = XSLeaksEvaluation() + def run_single_evaluation(self, eval_params: EvaluationParameters, worker_manager: WorkerManager) -> None: + browser_name = eval_params.browser_configuration.browser_name + experiment_name = eval_params.evaluation_range.mech_group + + logger.info(f"Starting evaluation for experiment '{experiment_name}' with browser '{browser_name}'") + + search_strategy = self.create_sequence_strategy(eval_params) + + try: + while (self.stop_gracefully or self.stop_forcefully) is False: + # Update search strategy with new potentially new results + current_state = search_strategy.next() + + # Prepare worker parameters + worker_params = eval_params.create_worker_params_for(current_state, self.db_connection_params) + + # Start worker to perform evaluation + worker_manager.start_test(worker_params) + + except SequenceFinished: + logger.debug('Last experiment has started') + self.state['reason'] = 'finished' + self.__update_eval_queue(eval_params.evaluation_range.mech_group, 'done') @staticmethod - def parse_search_strategy(sequence_config: SequenceConfiguration, state_list: list[State]): + def create_sequence_strategy(eval_params: EvaluationParameters) -> SequenceStrategy: + sequence_config = eval_params.sequence_configuration search_strategy = sequence_config.search_strategy sequence_limit = sequence_config.sequence_limit - if search_strategy == "bin_seq": - return NArySequence(state_list, 2, limit=sequence_limit) - if search_strategy == "bin_search": - return NArySearch(state_list, 2) - if search_strategy == "comp_search": - return CompositeSearch(state_list, 2, sequence_limit, NArySequence, NArySearch) - raise AttributeError("Unknown search strategy option '%s'" % search_strategy) - - def get_specific_evaluation_framework(self, evaluation_name: str) -> EvaluationFramework: - # TODO: we always use 'custom', in which evaluation_name is a project - evaluation_name = 'custom' - if evaluation_name not in self.available_evaluation_frameworks.keys(): - raise AttributeError("Could not find a framework for '%s'" % evaluation_name) - return self.available_evaluation_frameworks[evaluation_name] + outcome_checker = OutcomeChecker(sequence_config) + state_factory = StateFactory(eval_params, outcome_checker) + + if search_strategy == 'bgb_sequence': + strategy = BiggestGapBisectionSequence(state_factory, sequence_limit) + elif search_strategy == 'bgb_search': + strategy = BiggestGapBisectionSearch(state_factory) + elif search_strategy == 'comp_search': + strategy = CompositeSearch(state_factory, sequence_limit) + else: + raise AttributeError("Unknown search strategy option '%s'" % search_strategy) + return strategy def activate_stop_gracefully(self): if self.evaluation_framework: self.stop_gracefully = True - self.state = { - 'is_running': True, - 'reason': 'user', - 'status': 'waiting_to_stop' - } - Clients.push_info_to_all('state') + self.__update_state(is_running=True, reason='user', status='waiting_to_stop') self.evaluation_framework.stop_gracefully() - logger.info("Received user signal to gracefully stop.") + logger.info('Received user signal to gracefully stop.') else: - logger.info("Received user signal to gracefully stop, but no evaluation is running.") + logger.info('Received user signal to gracefully stop, but no evaluation is running.') - def activate_stop_forcefully(self): + def activate_stop_forcefully(self) -> None: if self.evaluation_framework: self.stop_forcefully = True - self.state = { - 'is_running': True, - 'reason': 'user', - 'status': 'waiting_to_stop' - } - Clients.push_info_to_all('state') + self.__update_state(is_running=True, reason='user', status='waiting_to_stop') self.evaluation_framework.stop_gracefully() - if self.worker_manager: - self.worker_manager.forcefully_stop_all_running_containers() - logger.info("Received user signal to forcefully stop.") + WorkerManager.forcefully_stop_all_running_containers() + logger.info('Received user signal to forcefully stop.') else: - logger.info("Received user signal to forcefully stop, but no evaluation is running.") + logger.info('Received user signal to forcefully stop, but no evaluation is running.') - def stop_bughog(self): - logger.info("Stopping all running BugHog containers...") + def stop_bughog(self) -> None: + logger.info('Stopping all running BugHog containers...') self.activate_stop_forcefully() mongodb_container.stop() - logger.info("Stopping BugHog core...") + logger.info('Stopping BugHog core...') exit(0) + + def __update_state(self, **kwargs) -> None: + for key, value in kwargs.items(): + self.state[key] = value + Clients.push_info_to_all('state') + + def __init_eval_queue(self, eval_params_list: list[EvaluationParameters]) -> None: + self.eval_queue = [] + for eval_params in eval_params_list: + self.eval_queue.append({ + 'experiment': eval_params.evaluation_range.mech_group, + 'state': 'pending' + }) + + def __update_eval_queue(self, experiment: str, state: str) -> None: + for eval in self.eval_queue: + if eval['experiment'] == experiment: + eval['state'] = state + return diff --git a/bci/proxy/ca_generator.py b/bci/proxy/ca_generator.py deleted file mode 100644 index 39ebe9a..0000000 --- a/bci/proxy/ca_generator.py +++ /dev/null @@ -1,91 +0,0 @@ -import subprocess -import sys -from datetime import datetime, timedelta - -from cryptography import x509 -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.x509.oid import NameOID - - -def generate_ca_files(pem_path: str = 'bci-ca.pem', crt_path: str = 'bci-ca.crt'): - # Generate a private key - private_key = rsa.generate_private_key( - public_exponent=65537, - key_size=2048, - backend=default_backend() - ) - - # Create a new certificate builder - builder = x509.CertificateBuilder() - - # Add a subject name to the certificate - subject_name = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, 'bci-ca'), - ]) - builder = builder.subject_name(subject_name) - - # Set the public key for the certificate - builder = builder.public_key(private_key.public_key()) - - # Set the serial number for the certificate - builder = builder.serial_number(x509.random_serial_number()) - - # Set the validity period for the certificate - builder = builder.not_valid_before(datetime.utcnow()) - builder = builder.not_valid_after(datetime.utcnow() + timedelta(days=365)) - - # Add X509v3 extensions - builder = builder.add_extension( - x509.KeyUsage( - digital_signature=False, - content_commitment=False, - key_encipherment=False, - data_encipherment=False, - key_agreement=False, - key_cert_sign=True, - crl_sign=False, - encipher_only=False, - decipher_only=False - ), - critical=True - ) - - builder = builder.add_extension( - x509.BasicConstraints(ca=True, path_length=None), - critical=True - ) - - builder = builder.issuer_name(subject_name) - - # Sign the certificate with the private key - certificate = builder.sign( - private_key=private_key, - algorithm=hashes.SHA256(), - backend=default_backend() - ) - - # Write the private key and certificate to a PEM file - with open(pem_path, 'wb') as f: - f.write(private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption() - )) - - f.write(certificate.public_bytes(encoding=serialization.Encoding.PEM)) - - # Generate CRT file - cmd = f"openssl x509 -outform der -in {pem_path} -out {crt_path}" - result = subprocess.run(cmd, shell=True, check=True) - if result.returncode != 0: - raise Exception(f"Failed to convert PEM to CRT. Error: {result.stderr}") - - -if __name__ == '__main__': - args = { - 'pem_path': sys.argv[1] if len(sys.argv) > 1 else 'bci-ca.pem', - 'crt_path': sys.argv[2] if len(sys.argv) > 2 else 'bci-ca.crt' - } - generate_ca_files(**args) diff --git a/bci/proxy/proxy.py b/bci/proxy/proxy.py deleted file mode 100644 index 6bb3bee..0000000 --- a/bci/proxy/proxy.py +++ /dev/null @@ -1,114 +0,0 @@ -import asyncio -import logging -import threading -import time - -from mitmproxy import http, options -from mitmproxy.tools.dump import DumpMaster - -logger = logging.getLogger('bci.proxy') - -HOST = '127.0.0.1' -PORT = 8080 - - -class ProxyThread(threading.Thread): - - __instance = None - - def __new__(cls): - if cls.__instance is None: - cls.__instance = super().__new__(cls) - cls.__instance.__initialized = False - return cls.__instance - - def __init__(self): - if self.__initialized: - self.__reset() - return - super().__init__() - self.__initialized = True - - self.__mitmproxy_master = None - self.__addon = None - self.__task = None - self.start() - - @property - def requests(self) -> list: - assert self.__addon is not None - return self.__addon.requests - - def __reset(self): - assert self.__addon is not None - self.__addon.requests = [] - self.__addon.responses = [] - logger.debug('Proxy has been reset') - - def run(self): - assert self.__mitmproxy_master is None - assert self.__task is None - - async def run(): - # Set up the options for mitmproxy - mitmproxy_options = options.Options( - listen_host=HOST, - listen_port=PORT, - ssl_insecure=True, - # ciphers_client='DEFAULT@SECLEVEL=0' - tls_version_client_min='UNBOUNDED' - # ssl_version_client='all', - # ssl_version_server='all' - ) - # Set up the mitmproxy dump master - self.__mitmproxy_master = DumpMaster(options=mitmproxy_options) - - class LogRequests: - - def __init__(self) -> None: - self.requests = [] - self.responses = [] - - def request(self, flow: http.HTTPFlow) -> None: - self.requests.append({ - 'url': flow.request.url, - 'method': flow.request.method, - 'headers': flow.request.headers, - 'content': flow.request.content - }) - self.responses.append(str(flow.response)) - - # Set the request and response handlers for mitmproxy - self.__addon = LogRequests() - self.__mitmproxy_master.addons.add(self.__addon) - # Start mitmproxy - self.__task = asyncio.create_task(self.__mitmproxy_master.run()) - try: - await self.__task - except asyncio.CancelledError: - logger.debug('Proxy task cancelled') - - def run_task(): - try: - logger.debug('Starting proxy task...') - asyncio.set_event_loop(asyncio.new_event_loop()) - asyncio.run(run()) - except Exception as e: - logger.error(f'Something went wrong {e}', exc_info=True) - raise - - run_task() - - -if __name__ == '__main__': - p1 = ProxyThread() - print('main') - time.sleep(5) - print(p1.requests) - - print('main2') - - p2 = ProxyThread() - print(p1.requests) - - time.sleep(100) diff --git a/bci/search_strategy/bgb_search.py b/bci/search_strategy/bgb_search.py new file mode 100644 index 0000000..a9f6a22 --- /dev/null +++ b/bci/search_strategy/bgb_search.py @@ -0,0 +1,88 @@ +import logging +from typing import Optional + +from bci.search_strategy.bgb_sequence import BiggestGapBisectionSequence +from bci.search_strategy.sequence_strategy import SequenceFinished +from bci.version_control.factory import StateFactory +from bci.version_control.states.state import State + +logger = logging.getLogger(__name__) + + +class BiggestGapBisectionSearch(BiggestGapBisectionSequence): + """ + This search strategy will split the biggest gap between two states in half and return the state in the middle. + It will only consider states where the non-None outcome differs. + It stops when there are no more states to evaluate between two states with different outcomes. + """ + + def __init__(self, state_factory: StateFactory) -> None: + """ + Initializes the search strategy. + + :param state_factory: The factory to create new states. + """ + super().__init__(state_factory, 0) + + def next(self) -> State: + """ + Returns the next state to evaluate. + """ + # Fetch all evaluated states + self._fetch_evaluated_states() + + if self._limit and self._limit <= len(self._completed_states): + raise SequenceFinished() + + if self._lower_state not in self._completed_states: + self._add_state(self._lower_state) + return self._lower_state + if self._upper_state not in self._completed_states: + self._add_state(self._upper_state) + return self._upper_state + + while next_pair := self.__get_next_pair_to_split(): + splitter_state = self._find_best_splitter_state(next_pair[0], next_pair[1]) + if splitter_state is None: + self._unavailability_gap_pairs.add(next_pair) + if splitter_state: + logger.debug(f'Splitting [{next_pair[0].index}]--/{splitter_state.index}/--[{next_pair[1].index}]') + self._add_state(splitter_state) + return splitter_state + raise SequenceFinished() + + def __get_next_pair_to_split(self) -> Optional[tuple[State, State]]: + """ + Returns the next pair of states to split. + """ + # Make pairwise list of states and remove pairs with the same outcome + states = self._completed_states + pairs = [(state1, state2) for state1, state2 in zip(states, states[1:]) if state1.outcome != state2.outcome] + if not pairs: + return None + # Remove the first and last pair if they have a first and last state with a None outcome, respectively + if pairs[0][0].outcome is None: + pairs = pairs[1:] + if pairs[-1][1].outcome is None: + pairs = pairs[:-1] + # Remove all pairs that have already been identified as unavailability gaps + pairs = [pair for pair in pairs if pair not in self._unavailability_gap_pairs] + # Remove any pair where the same None-outcome state is present in a pair where the sibling states have the same outcome + pairs_with_failed = [pair for pair in pairs if pair[0].outcome is None or pair[1].outcome is None] + for i in range(0, len(pairs_with_failed), 2): + if i + 1 >= len(pairs_with_failed): + break + first_pair = pairs_with_failed[i] + second_pair = pairs_with_failed[i + 1] + if first_pair[0].outcome == second_pair[1].outcome: + pairs.remove(first_pair) + pairs.remove(second_pair) + + if not pairs: + return None + # Sort pairs to prioritize pairs with bigger gaps. + # This way, we refrain from pinpointing pair-by-pair, making the search more efficient. + # E.g., when the splitter of the first gap is being evaluated, we can already evaluate the + # splitter of the second gap with having to wait for the first gap to be fully evaluated. + pairs.sort(key=lambda pair: pair[1].index - pair[0].index, reverse=True) + return pairs[0] diff --git a/bci/search_strategy/bgb_sequence.py b/bci/search_strategy/bgb_sequence.py new file mode 100644 index 0000000..92a9b77 --- /dev/null +++ b/bci/search_strategy/bgb_sequence.py @@ -0,0 +1,91 @@ +import logging +from typing import Optional + +from bci.search_strategy.sequence_strategy import SequenceFinished, SequenceStrategy +from bci.version_control.factory import StateFactory +from bci.version_control.states.state import State + +logger = logging.getLogger(__name__) + + +class BiggestGapBisectionSequence(SequenceStrategy): + """ + This sequence strategy will split the biggest gap between two states in half and return the state in the middle. + """ + + def __init__(self, state_factory: StateFactory, limit: int) -> None: + """ + Initializes the sequence strategy. + + :param state_factory: The factory to create new states. + :param limit: The maximum number of states to evaluate. 0 means no limit. + """ + super().__init__(state_factory, limit) + self._unavailability_gap_pairs: set[tuple[State, State]] = set() + """Tuples in this list are **strict** boundaries of ranges without any available binaries.""" + + def next(self) -> State: + """ + Returns the next state to evaluate. + """ + # Fetch all evaluated states on the first call + if not self._completed_states: + self._fetch_evaluated_states() + + if self._limit and self._limit <= len(self._completed_states): + raise SequenceFinished() + + if self._lower_state not in self._completed_states: + self._add_state(self._lower_state) + return self._lower_state + if self._upper_state not in self._completed_states: + self._add_state(self._upper_state) + return self._upper_state + + pairs = list(zip(self._completed_states, self._completed_states[1:])) + while pairs: + filtered_pairs = [pair for pair in pairs if not self._pair_is_in_unavailability_gap(pair)] + furthest_pair = max(filtered_pairs, key=lambda x: x[1].index - x[0].index) + splitter_state = self._find_best_splitter_state(furthest_pair[0], furthest_pair[1]) + if splitter_state is None: + self._unavailability_gap_pairs.add(furthest_pair) + elif splitter_state: + logger.debug( + f'Splitting [{furthest_pair[0].index}]--/{splitter_state.index}/--[{furthest_pair[1].index}]' + ) + self._add_state(splitter_state) + return splitter_state + pairs.remove(furthest_pair) + raise SequenceFinished() + + def _find_best_splitter_state(self, first_state: State, last_state: State) -> Optional[State]: + """ + Returns the most suitable state that splits the gap between the two states. + The state should be as close as possible to the middle of the gap and should have an available binary. + """ + if first_state.index + 1 == last_state.index: + return None + best_splitter_index = first_state.index + (last_state.index - first_state.index) // 2 + target_state = self._state_factory.create_state(best_splitter_index) + return self._find_closest_state_with_available_binary(target_state, (first_state, last_state)) + + def _state_is_in_unavailability_gap(self, state: State) -> bool: + """ + Returns True if the state is in a gap between two states without any available binaries. + """ + for pair in self._unavailability_gap_pairs: + if pair[0].index < state.index < pair[1].index: + return True + return False + + def _pair_is_in_unavailability_gap(self, pair: tuple[State, State]) -> bool: + """ + Returns True if the pair of states is in a gap between two states without any available binaries + """ + for gap_pair in self._unavailability_gap_pairs: + if ( + gap_pair[0].index < pair[0].index < gap_pair[1].index + and gap_pair[0].index < pair[1].index < gap_pair[1].index + ): + return True + return False diff --git a/bci/search_strategy/composite_search.py b/bci/search_strategy/composite_search.py index 60fbc23..f59a7c1 100644 --- a/bci/search_strategy/composite_search.py +++ b/bci/search_strategy/composite_search.py @@ -1,89 +1,23 @@ -from bci.search_strategy.sequence_strategy import SequenceStrategy -from bci.search_strategy.n_ary_sequence import NArySequence, SequenceFinished -from bci.search_strategy.n_ary_search import NArySearch -from bci.search_strategy.sequence_elem import SequenceElem, ElemState +from bci.search_strategy.bgb_search import BiggestGapBisectionSearch +from bci.search_strategy.bgb_sequence import BiggestGapBisectionSequence +from bci.search_strategy.sequence_strategy import SequenceFinished +from bci.version_control.factory import StateFactory from bci.version_control.states.state import State -class CompositeSearch(SequenceStrategy): - def __init__( - self, - values: list[State], - n: int, - sequence_limit: int, - sequence_strategy_class: NArySequence.__class__, - search_strategy_class: NArySearch.__class__) -> None: - super().__init__(values) - self.n = n - self.sequence_strategy = sequence_strategy_class(values, n, limit=sequence_limit) - self.search_strategies = [] - self.search_strategy_class = search_strategy_class +class CompositeSearch(): + def __init__(self, state_factory: StateFactory, sequence_limit: int) -> None: + self.sequence_strategy = BiggestGapBisectionSequence(state_factory, limit=sequence_limit) + self.search_strategy = BiggestGapBisectionSearch(state_factory) self.sequence_strategy_finished = False def next(self) -> State: + # First we use the sequence strategy to select the next state if not self.sequence_strategy_finished: - next_elem = self.next_in_sequence_strategy() - if next_elem is not None: - return next_elem - return self.next_in_search_strategy() - - def next_in_sequence_strategy(self) -> State: - try: - return self.sequence_strategy.next() - except SequenceFinished: - self.sequence_strategy_finished = True - self.prepare_search_strategies() - return None - - def next_in_search_strategy(self) -> State: - while True: - if not self.search_strategies: - raise SequenceFinished() - search_strategy = self.search_strategies[0] try: - return search_strategy.next() + return self.sequence_strategy.next() except SequenceFinished: - del self.search_strategies[0] - - def get_active_strategy(self) -> SequenceStrategy: - ''' - Returns the currently active sequence/search strategy. - Returns None if all sequence/search strategies are finished. - ''' - if not self.sequence_strategy_finished: - return self.sequence_strategy - elif self.search_strategies: - return self.search_strategies[0] + self.sequence_strategy_finished = True + return self.search_strategy.next() else: - return None - - def update_outcome(self, elem: State, outcome: bool) -> None: - if active_strategy := self.get_active_strategy(): - active_strategy.update_outcome(elem, outcome) - # We only update the outcome of this object too if we are still using the sequence strategy - # because the elem lists need to be synced up until the search strategies are prepared. - # Not very clean, but does the job for now. - if not self.sequence_strategy_finished: - super().update_outcome(elem, outcome) - - def prepare_search_strategies(self): - shift_index_pairs = self.find_all_shift_index_pairs() - self.search_strategies = [self.search_strategy_class( - self.sequence_strategy.values[left_shift_index:right_shift_index+1], - self.n, - prior_elems=self.get_elems_slice(left_shift_index, right_shift_index+1)) - for left_shift_index, right_shift_index in shift_index_pairs] - - def get_elems_slice(self, start: int, end: int) -> list[SequenceElem]: - return [elem.get_deep_copy(index=i) for i, elem in enumerate(self._elems[start:end])] - - def find_all_shift_index_pairs(self) -> list[tuple[int, int]]: - # Filter out all errors and unevaluated elements - filtered_elems = [elem for elem in self._elems if elem.state not in [ElemState.ERROR, ElemState.INITIALIZED]] - filtered_elems_outcomes = [elem.outcome for elem in filtered_elems] - # Get start indexes of shift in outcome - shift_indexes = [i for i in range(0, len(filtered_elems_outcomes) - 1) if filtered_elems_outcomes[i] != filtered_elems_outcomes[i+1]] - # Convert to index pairs for original value list - shift_elem_pairs = [(filtered_elems[shift_index], filtered_elems[shift_index + 1]) for shift_index in shift_indexes if shift_index + 1 < len(filtered_elems)] - shift_index_pairs = [(left_shift_elem.index, right_shift_elem.index) for left_shift_elem, right_shift_elem in shift_elem_pairs] - return shift_index_pairs + return self.search_strategy.next() diff --git a/bci/search_strategy/n_ary_search.py b/bci/search_strategy/n_ary_search.py deleted file mode 100644 index 38e0279..0000000 --- a/bci/search_strategy/n_ary_search.py +++ /dev/null @@ -1,81 +0,0 @@ -from bisect import insort - -from bci.search_strategy.sequence_elem import SequenceElem -from bci.search_strategy.n_ary_sequence import NArySequence, SequenceFinished, ElemState -from bci.version_control.states.state import State - - -class NArySearch(NArySequence): - - def __init__(self, values: list[State], n: int, prior_elems: list[SequenceElem] = None) -> None: - super().__init__(values, n, prior_elems=prior_elems) - self.lower_bound = 0 - """ - Lower boundary, only indexes equal or higher should be evaluated. - """ - self.upper_bound = len(values) - """ - Strict upper boundary, only indexes strictly lower should be evaluated. - """ - self.outcomes: list[tuple[int, bool]] = [] - if prior_elems: - for elem in prior_elems: - if elem.outcome is not None: - self.update_boundaries(elem.value, elem.outcome) - - def update_outcome(self, value: State, outcome: bool) -> None: - super().update_outcome(value, outcome) - self.update_boundaries(value, outcome) - - def update_boundaries(self, value: State, outcome: bool) -> None: - if outcome is None: - return - new_index = self._elem_info[value].index - insort(self.outcomes, (new_index, outcome), key=lambda x: x[0]) - if len(self.outcomes) < 3: - return - index0, outcome0 = self.outcomes[0] - index1, outcome1 = self.outcomes[1] - index2, outcome2 = self.outcomes[2] - if outcome0 != outcome1: - del self.outcomes[2] - self.lower_bound = index0 - self.upper_bound = index1 - elif outcome1 != outcome2: - del self.outcomes[0] - self.lower_bound = index1 - self.upper_bound = index2 - lower_value = self._elems[self.lower_bound].value - upper_value = self._elems[self.upper_bound - 1].value - self.logger.info(f"Boundaries updated: {lower_value} <= x <= {upper_value}") - - def next(self) -> State: - while True: - while self.index_queue.empty(): - if self.range_queue.empty(): - raise SequenceFinished() - (lower_index, upper_index) = self.range_queue.get() - if lower_index >= self.upper_bound or upper_index < self.lower_bound: - # The range is completely out of bounds, so we just discard it - continue - if lower_index < self.lower_bound: - # The range is partly out of bounds, so we truncate it - # (possible because closest available elem instead of exact elem) - lower_index = self.lower_bound - if upper_index > self.upper_bound: - # Same as above - upper_index = self.upper_bound - new_indexes, new_ranges = self.divide_range(lower_index, upper_index, self.n) - for new_index in new_indexes: - self.index_queue.put(new_index) - for new_range in new_ranges: - self.range_queue.put(new_range) - index = self.index_queue.get() - # Only use index if it's within the active bounds - # Could not be the case if the index was added to the queue after the bounds were updated - if self.lower_bound <= index < self.upper_bound: - # Get closest available elem and check whether it is not yet evaluated - closest_available_elem = self.find_closest_available_elem(index) - if closest_available_elem.state == ElemState.INITIALIZED: - closest_available_elem.state = ElemState.IN_PROGRESS - return closest_available_elem.value diff --git a/bci/search_strategy/n_ary_sequence.py b/bci/search_strategy/n_ary_sequence.py deleted file mode 100644 index 6de0f12..0000000 --- a/bci/search_strategy/n_ary_sequence.py +++ /dev/null @@ -1,59 +0,0 @@ -import math -from queue import Queue -from bci.search_strategy.sequence_elem import ElemState, SequenceElem -from bci.search_strategy.sequence_strategy import SequenceStrategy, SequenceFinished -from bci.version_control.states.state import State - - -class NArySequence(SequenceStrategy): - - def __init__(self, values: list[State], n: int, limit=float('inf'), prior_elems: list[SequenceElem] = None) -> None: - super().__init__(values, prior_elems=prior_elems) - self.n = n - first_index = 0 - last_index = len(self._elems) - 1 - self.index_queue = Queue() - self.index_queue.put(first_index) - self.index_queue.put(last_index) - self.range_queue = Queue() - self.range_queue.put((first_index + 1, last_index)) - self.limit = limit - self.nb_of_started_evaluations = 0 - - def next(self) -> State: - while True: - if self.limit <= self.nb_of_started_evaluations: - raise SequenceFinished() - while self.index_queue.empty(): - if self.range_queue.empty(): - raise SequenceFinished() - (lower_index, higher_index) = self.range_queue.get() - new_indexes, new_ranges = self.divide_range(lower_index, higher_index, self.n) - for new_index in new_indexes: - self.index_queue.put(new_index) - for new_range in new_ranges: - self.range_queue.put(new_range) - target_elem = self.index_queue.get() - closest_available_elem = self.find_closest_available_elem(target_elem) - self.logger.debug(f"Next state should be {repr(target_elem)}, but {repr(closest_available_elem)} is closest available") - if closest_available_elem.state == ElemState.INITIALIZED: - closest_available_elem.state = ElemState.IN_PROGRESS - self.nb_of_started_evaluations += 1 - return closest_available_elem.value - - @staticmethod - def divide_range(lower_index, higher_index, n): - if lower_index == higher_index: - return [], [] - if higher_index - lower_index + 1 <= n: - return list(range(lower_index, higher_index)), [] - step = math.ceil((higher_index - lower_index) / n) - if lower_index + step * n <= higher_index: - indexes = list(range(lower_index, higher_index + 1, step)) - else: - indexes = list(range(lower_index, higher_index + 1, step)) + [higher_index] - ranges = [] - ranges.append((indexes[0], indexes[1])) - for i in range(1, len(indexes) - 1): - ranges.append((indexes[i] + 1, indexes[i + 1])) - return indexes[1:-1], ranges diff --git a/bci/search_strategy/sequence_elem.py b/bci/search_strategy/sequence_elem.py deleted file mode 100644 index 156714f..0000000 --- a/bci/search_strategy/sequence_elem.py +++ /dev/null @@ -1,45 +0,0 @@ -from enum import Enum - -import bci.browser.binary.factory as binary_factory -from bci.version_control.states.state import State - - -class ElemState(Enum): - INITIALIZED = 0 - UNAVAILABLE = 1 - IN_PROGRESS = 2 - ERROR = 3 - DONE = 4 - - -class SequenceElem: - - def __init__(self, index: int, value: State, state: ElemState = ElemState.INITIALIZED, outcome: bool = None) -> None: - self.value = value - self.index = index - if state == ElemState.DONE and outcome is None: - raise AttributeError("Every sequence element that has been evaluated should have an outcome") - self.state = state - self.outcome = outcome - - def is_available(self) -> bool: - binary = binary_factory.get_binary(self.value) - return binary.is_available() - - def update_outcome(self, outcome: bool): - if self.state == ElemState.DONE: - raise AttributeError(f"Outcome was already set to DONE for {repr(self)}") - if outcome is None: - self.state = ElemState.ERROR - else: - self.state = ElemState.DONE - self.outcome = outcome - - def get_deep_copy(self, index=None): - if index is not None: - return SequenceElem(index, self.value, state=self.state, outcome=self.outcome) - else: - return SequenceElem(self.index, self.value, state=self.state, outcome=self.outcome) - - def __repr__(self) -> str: - return f"{str(self.value)}: {self.state}" diff --git a/bci/search_strategy/sequence_strategy.py b/bci/search_strategy/sequence_strategy.py index 74cc175..3a6eaff 100644 --- a/bci/search_strategy/sequence_strategy.py +++ b/bci/search_strategy/sequence_strategy.py @@ -1,80 +1,125 @@ import logging from abc import abstractmethod -from threading import Thread -import bci.browser.binary.factory as binary_factory -from bci.search_strategy.sequence_elem import SequenceElem +from concurrent.futures import ThreadPoolExecutor +from typing import Optional + +from bci.version_control.factory import StateFactory from bci.version_control.states.state import State +logger = logging.getLogger(__name__) + class SequenceStrategy: - def __init__(self, values: list[State], prior_elems: list[SequenceElem] = None) -> None: - self.logger = logging.getLogger(__name__) - if prior_elems and len(values) != len(prior_elems): - raise AttributeError(f"List of values and list of elems should be of equal length ({len(values)} != {len(prior_elems)})") - self.values = values - if prior_elems: - self._elems = prior_elems - else: - self._elems = [SequenceElem(index, value) for index, value in enumerate(values)] - self._elem_info = { - elem.value: elem - for elem in self._elems - } - - def update_outcome(self, elem: State, outcome: bool) -> None: - self._elem_info[elem].update_outcome(outcome) + def __init__(self, state_factory: StateFactory, limit) -> None: + """ + Initializes the sequence strategy. - def is_available(self, state: State) -> bool: - return binary_factory.binary_is_available(state) + :param state_factory: The factory to create new states. + :param limit: The maximum number of states to evaluate. 0 means no limit. + """ + self._state_factory = state_factory + self._limit = limit + self._lower_state, self._upper_state = self.__create_available_boundary_states() + self._completed_states = [] @abstractmethod def next(self) -> State: pass - def find_closest_available_elem(self, target_index: int) -> SequenceElem: - diff = 0 - while True: - potential_indexes = set(index for index in [ - target_index + diff, - target_index + diff + 1, - target_index - diff, - target_index - diff - 1, - ] if 0 <= index < len(self._elems)) - - if not potential_indexes: - raise AttributeError(f"Could not find closest available build state for '{target_index}'") - threads = [] - for index in potential_indexes: - thread = ThreadWithReturnValue(target=lambda x: x if self._elems[x].is_available() else None, args=(index,)) - thread.start() - threads.append(thread) - - results = [] - for thread in threads: - result = thread.join() - if result is not None: - results.append(result) - # If valid results are found, return the one closest to target - if results: - results = sorted(results, key=lambda x: abs(x - target_index)) - return self._elems[results[0]] - # Otherwise re-iterate + def is_available(self, state: State) -> bool: + return state.has_available_binary() + + def _add_state(self, elem: State) -> None: + """ + Adds an element to the list of evaluated states and sorts the list. + """ + self._completed_states.append(elem) + self._completed_states.sort(key=lambda x: x.index) + + def _fetch_evaluated_states(self) -> None: + """ + Fetches all evaluated states from the database and stores them in the list of evaluated states. + """ + fetched_states = self._state_factory.create_evaluated_states() + for state in self._completed_states: + if state not in fetched_states: + fetched_states.append(state) + fetched_states.sort(key=lambda x: x.index) + self._completed_states = fetched_states + + def __create_available_boundary_states(self) -> tuple[State, State]: + first_state, last_state = self._state_factory.boundary_states + available_first_state = self._find_closest_state_with_available_binary(first_state, (first_state, last_state)) + available_last_state = self._find_closest_state_with_available_binary(last_state, (first_state, last_state)) + if available_first_state is None or available_last_state is None: + raise AttributeError( + f"Could not find boundary states for '{self._lower_state.index}' and '{self._upper_state.index}'" + ) + return available_first_state, available_last_state + + def _find_closest_state_with_available_binary(self, target: State, boundaries: tuple[State, State]) -> State | None: + """ + Finds the closest state with an available binary **strictly** within the given boundaries. + """ + if target.has_available_binary(): + return target + + try: + if state := self.__get_closest_available_state(target, boundaries): + return state + else: + return None + except FunctionalityNotAvailable: + pass + + def index_has_available_binary(index: int) -> Optional[State]: + state = self._state_factory.create_state(index) + if state.has_available_binary(): + return state + else: + return None + + diff = 1 + first_state, last_state = boundaries + best_splitter_index = target.index + while (best_splitter_index - diff) > first_state.index or (best_splitter_index + diff) < last_state.index: + with ThreadPoolExecutor(max_workers=6) as executor: + futures = [] + for offset in (-diff, diff, - 1 - diff, 1 + diff, - 2 - diff, 2 + diff): + target_index = best_splitter_index + offset + if first_state.index < target_index < last_state.index: + futures.append(executor.submit(index_has_available_binary, target_index)) + + for future in futures: + state = future.result() + if state: + return state + diff += 2 + return None -class SequenceFinished(Exception): - pass + def __get_closest_available_state(self, target: State, boundaries: tuple[State, State]) -> State | None: + """ + Return the closest state with an available binary. + """ + try: + states = target.get_previous_and_next_state_with_binary() + states = [state for state in states if state is not None] + ordered_states = sorted(states, key=lambda x: abs(target.revision_nb - x.revision_nb)) + for state in ordered_states: + if boundaries[0].revision_nb < state.revision_nb < boundaries[1].revision_nb: + return state -class ThreadWithReturnValue(Thread): - def __init__(self, group=None, target=None, name=None, args=(), kwargs={}, Verbose=None): - Thread.__init__(self, group=group, target=target, name=name, args=args, kwargs=kwargs) - self._return = None + return None - def run(self): - if self._target is not None: - self._return = self._target(*self._args, **self._kwargs) + except NotImplementedError as e: + raise FunctionalityNotAvailable() from e - def join(self, *args): - Thread.join(self, *args) - return self._return + +class SequenceFinished(Exception): + pass + +class FunctionalityNotAvailable(Exception): + pass diff --git a/bci/util.py b/bci/util.py index 81516c8..ffaf965 100644 --- a/bci/util.py +++ b/bci/util.py @@ -10,7 +10,7 @@ import requests -LOGGER = logging.getLogger('bci') +LOGGER = logging.getLogger(__name__) def safe_move_file(src_path, dst_path): @@ -64,7 +64,7 @@ def read_web_report(file_name): report_folder = "/reports" path = os.path.join(report_folder, file_name) if not os.path.isfile(path): - raise AttributeError("Could not find report at '%s'" % path) + raise PageNotFound("Could not find report at '%s'" % path) with open(path, "r") as file: return json.load(file) @@ -72,24 +72,28 @@ def read_web_report(file_name): def request_html(url: str): LOGGER.debug(f"Requesting {url}") resp = requests.get(url, timeout=60) - if resp.status_code != 200: - raise AttributeError(f"Could not connect to url '{url}'") + if resp.status_code >= 400: + raise PageNotFound(f"Could not connect to url '{url}'") return resp.content def request_json(url: str): LOGGER.debug(f"Requesting {url}") resp = requests.get(url, timeout=60) - if resp.status_code != 200: - raise AttributeError(f"Could not connect to url '{url}'") - LOGGER.debug(f"Request completed") + if resp.status_code >= 400: + raise PageNotFound(f"Could not connect to url '{url}'") + LOGGER.debug('Request completed') return resp.json() def request_final_url(url: str) -> str: LOGGER.debug(f"Requesting {url}") resp = requests.get(url, timeout=60) - if resp.status_code != 200: - raise AttributeError(f"Could not connect to url '{url}'") - LOGGER.debug(f"Request completed") + if resp.status_code >= 400: + raise PageNotFound(f"Could not connect to url '{url}'") + LOGGER.debug('Request completed') return resp.url + + +class PageNotFound(Exception): + pass diff --git a/bci/version_control/factory.py b/bci/version_control/factory.py index f203fd5..f275801 100644 --- a/bci/version_control/factory.py +++ b/bci/version_control/factory.py @@ -1,81 +1,92 @@ -import re +from __future__ import annotations -import bci.version_control.repository.online.chromium as chromium_repo -import bci.version_control.repository.online.firefox as firefox_repo - -from bci.evaluations.logic import BrowserConfiguration, EvaluationRange -from bci.version_control.repository.repository import Repository +from bci.database.mongo.mongodb import MongoDB +from bci.evaluations.logic import EvaluationParameters +from bci.evaluations.outcome_checker import OutcomeChecker from bci.version_control.states.revisions.chromium import ChromiumRevision from bci.version_control.states.revisions.firefox import FirefoxRevision from bci.version_control.states.state import State +from bci.version_control.states.versions.base import BaseVersion from bci.version_control.states.versions.chromium import ChromiumVersion from bci.version_control.states.versions.firefox import FirefoxVersion -def create_state_collection(browser_config: BrowserConfiguration, eval_range: EvaluationRange) -> list[State]: - if eval_range.only_release_revisions: - return __create_version_collection(browser_config, eval_range) - else: - return __create_revision_collection(browser_config, eval_range) - - -def __create_version_collection(browser_config: BrowserConfiguration, eval_range: EvaluationRange) -> list[State]: - if not eval_range.major_version_range: - raise ValueError('A major version range is required for creating a version collection') - lower_version = eval_range.major_version_range[0] - upper_version = eval_range.major_version_range[1] - - match browser_config.browser_name: - case 'chromium': - state_class = ChromiumVersion - case 'firefox': - state_class = FirefoxVersion - case _: - raise ValueError(f'Unknown browser name: {browser_config.browser_name}') - - return [ - state_class(version) - for version in range(lower_version, upper_version + 1) - ] - - -def __create_revision_collection(browser_config: BrowserConfiguration, eval_range: EvaluationRange) -> list[State]: - if eval_range.major_version_range: - repo = __get_repo(browser_config) - lower_revision_nb = repo.get_release_revision_number(eval_range.major_version_range[0]) - upper_revision_nb = repo.get_release_revision_number(eval_range.major_version_range[1]) - else: - lower_revision_nb, upper_revision_nb = eval_range.revision_number_range - - match browser_config.browser_name: - case 'chromium': - state_class = ChromiumRevision - case 'firefox': - state_class = FirefoxRevision - case _: - raise ValueError(f'Unknown browser name: {browser_config.browser_name}') - - return [ - state_class(revision_number=rev_nb) - for rev_nb in range(lower_revision_nb, upper_revision_nb + 1) - ] - - -def __get_short_version(version: str) -> int: - if '.' not in version: - return int(version) - if re.match(r'^[0-9]+$', version): - return int(version) - if re.match(r'^[0-9]+(\.[0-9]+)+$', version): - return int(version.split(".")[0]) - raise AttributeError(f'Could not convert version \'{version}\' to short version') - - -def __get_repo(browser_config: BrowserConfiguration) -> Repository: - match browser_config.browser_name: - case 'chromium': - return chromium_repo - case 'firefox': - return firefox_repo - case _: - raise ValueError(f'Unknown browser name: {browser_config.browser_name}') +class StateFactory: + def __init__(self, eval_params: EvaluationParameters, outcome_checker: OutcomeChecker) -> None: + """ + Create a state factory object with the given evaluation parameters and boundary indices. + + :param eval_params: The evaluation parameters. + """ + self.__eval_params = eval_params + self.__outcome_checker = outcome_checker + self.boundary_states = self.__create_boundary_states() + + def create_state(self, index: int) -> State: + """ + Create a state object associated with the given index. + The given index represents: + - A major version number if `self.eval_params.evaluation_range.major_version_range` is True. + - A revision number otherwise. + + :param index: The index of the state. + """ + eval_range = self.__eval_params.evaluation_range + if eval_range.only_release_revisions: + return self.__create_version_state(index) + else: + return self.__create_revision_state(index) + + def __create_boundary_states(self) -> tuple[State, State]: + """ + Create the boundary state objects for the evaluation range. + """ + eval_range = self.__eval_params.evaluation_range + if eval_range.major_version_range: + first_state = self.__create_version_state(eval_range.major_version_range[0]) + last_state = self.__create_version_state(eval_range.major_version_range[1]) + if not eval_range.only_release_revisions: + first_state = first_state.convert_to_revision() + last_state = last_state.convert_to_revision() + return first_state, last_state + elif eval_range.revision_number_range: + if eval_range.only_release_revisions: + raise ValueError('Release revisions are not allowed in this evaluation range') + return ( + self.__create_revision_state(eval_range.revision_number_range[0]), + self.__create_revision_state(eval_range.revision_number_range[1]), + ) + else: + raise ValueError('No evaluation range specified') + + def create_evaluated_states(self) -> list[State]: + """ + Create evaluated state objects within the evaluation range where the result is fetched from the database. + """ + return MongoDB().get_evaluated_states(self.__eval_params, self.boundary_states, self.__outcome_checker) + + def __create_version_state(self, index: int) -> BaseVersion: + """ + Create a version state object associated with the given index. + """ + browser_config = self.__eval_params.browser_configuration + match browser_config.browser_name: + case 'chromium': + return ChromiumVersion(index) + case 'firefox': + return FirefoxVersion(index) + case _: + raise ValueError(f'Unknown browser name: {browser_config.browser_name}') + + def __create_revision_state(self, index: int) -> State: + """ + Create a revision state object associated with the given index. + """ + browser_config = self.__eval_params.browser_configuration + match browser_config.browser_name: + case 'chromium': + return ChromiumRevision(revision_nb=index) + case 'firefox': + return FirefoxRevision(revision_nb=index) + case _: + raise ValueError(f'Unknown browser name: {browser_config.browser_name}') diff --git a/bci/version_control/revision_parser/chromium_parser.py b/bci/version_control/revision_parser/chromium_parser.py index 027d54c..df643b4 100644 --- a/bci/version_control/revision_parser/chromium_parser.py +++ b/bci/version_control/revision_parser/chromium_parser.py @@ -1,29 +1,34 @@ import logging import re from typing import Optional -from bci.version_control.revision_parser.parser import RevisionParser -from bci.util import request_html, request_final_url +from bci.util import PageNotFound, request_final_url, request_html +from bci.version_control.revision_parser.parser import RevisionParser REV_ID_BASE_URL = 'https://chromium.googlesource.com/chromium/src/+/' REV_NUMBER_BASE_URL = 'http://crrev.com/' +logger = logging.getLogger(__name__) -class ChromiumRevisionParser(RevisionParser): - def get_rev_id(self, rev_number: int) -> str: - final_url = request_final_url(f'{REV_NUMBER_BASE_URL}{rev_number}') +class ChromiumRevisionParser(RevisionParser): + def get_revision_id(self, revision_nb: int) -> Optional[str]: + try: + final_url = request_final_url(f'{REV_NUMBER_BASE_URL}{revision_nb}') + except PageNotFound: + logger.warning(f"Could not find revision id for revision number '{revision_nb}'") + return None rev_id = final_url[-40:] assert re.match(r'[a-z0-9]{40}', rev_id) return rev_id - def get_rev_number(self, rev_id: str) -> int: - url = f'{REV_ID_BASE_URL}{rev_id}' + def get_revision_nb(self, revision_id: str) -> int: + url = f'{REV_ID_BASE_URL}{revision_id}' html = request_html(url).decode() rev_number = self.__parse_revision_number(html) if rev_number is None: - logging.getLogger('bci').error(f'Could not parse revision number on \'{url}\'') - raise AttributeError(f'Could not parse revision number on \'{url}\'') + logging.getLogger('bci').error(f"Could not parse revision number on '{url}'") + raise AttributeError(f"Could not parse revision number on '{url}'") assert re.match(r'[0-9]{1,7}', rev_number) return int(rev_number) diff --git a/bci/version_control/revision_parser/parser.py b/bci/version_control/revision_parser/parser.py index 26551f2..e3e2595 100644 --- a/bci/version_control/revision_parser/parser.py +++ b/bci/version_control/revision_parser/parser.py @@ -1,12 +1,12 @@ from abc import abstractmethod +from typing import Optional class RevisionParser: - @abstractmethod - def get_rev_id(self, rev_nb: int): + def get_revision_id(self, revision_nb: int) -> Optional[str]: pass @abstractmethod - def get_rev_number(self, rev_id: str): + def get_revision_nb(self, revision_id: str) -> Optional[int]: pass diff --git a/bci/version_control/states/revisions/base.py b/bci/version_control/states/revisions/base.py index c4e6eaf..52959a1 100644 --- a/bci/version_control/states/revisions/base.py +++ b/bci/version_control/states/revisions/base.py @@ -1,135 +1,101 @@ +import logging import re from abc import abstractmethod +from typing import Optional from bci.version_control.states.state import State +logger = logging.getLogger(__name__) -class BaseRevision(State): - def __init__(self, revision_id: str = None, revision_number: int = None, parents=None, children=None): +class BaseRevision(State): + def __init__(self, revision_id: Optional[str] = None, revision_nb: Optional[int] = None): super().__init__() - self._revision_id = None - self._revision_number = None - if revision_id is None and revision_number is None: - raise Exception('A state must be initiliazed with either a revision id or revision number') - if revision_id is not None: - self.revision_id = revision_id - if revision_number is not None: - self.revision_number = revision_number - self.parents = [] if parents is None else parents - self.children = [] if children is None else children - self.result = [] - self.evaluation_target = False + if revision_id is None and revision_nb is None: + raise AttributeError('A state must be initiliazed with either a revision id or revision number') + + self._revision_id = revision_id + self._revision_nb = revision_nb + self._fetch_missing_data() + + if self._revision_id is not None and not self._is_valid_revision_id(self._revision_id): + raise AttributeError(f"Invalid revision id '{self._revision_id}' for state '{self}'") + + if self._revision_nb is not None and not self._is_valid_revision_number(self._revision_nb): + raise AttributeError(f"Invalid revision number '{self._revision_nb}' for state '{self}'") @property @abstractmethod - def browser_name(self): + def browser_name(self) -> str: pass @property - def name(self): - return f'{self.revision_number}' + def name(self) -> str: + return f'{self._revision_nb}' + + @property + def type(self) -> str: + return 'revision' + + @property + def index(self) -> int: + return self._revision_nb - def to_dict(self, make_complete: bool = True) -> dict: - ''' + @property + def revision_nb(self) -> int: + return self._revision_nb + + def to_dict(self) -> dict: + """ Returns a dictionary representation of the state. - If complete is True, any missing information will be fetched. - For example, only the revision id might be known, but not the revision number. - ''' - if make_complete: - return { - 'type': 'revision', - 'browser_name': self.browser_name, - 'revision_id': self.revision_id, - 'revision_number': self.revision_number - } - else: - state_dict = { - 'type': 'revision', - 'browser_name': self.browser_name - } - if self._revision_id is not None: - state_dict['revision_id'] = self._revision_id - if self._revision_number is not None: - state_dict['revision_number'] = self._revision_number - return state_dict + """ + state_dict = {'type': self.type, 'browser_name': self.browser_name} + if self._revision_id: + state_dict['revision_id'] = self._revision_id + if self._revision_nb: + state_dict['revision_number'] = self._revision_nb + return state_dict @staticmethod def from_dict(data: dict) -> State: - from bci.version_control.states.revisions.chromium import \ - ChromiumRevision - from bci.version_control.states.revisions.firefox import \ - FirefoxRevision + from bci.version_control.states.revisions.chromium import ChromiumRevision + from bci.version_control.states.revisions.firefox import FirefoxRevision + match data['browser_name']: case 'chromium': - return ChromiumRevision( - revision_id=data['revision_id'], revision_number=data['revision_number'] - ) + state = ChromiumRevision(revision_id=data.get('revision_id', None), revision_nb=data['revision_number']) case 'firefox': - return FirefoxRevision( - revision_id=data['revision_id'], revision_number=data['revision_number'] - ) + state = FirefoxRevision(revision_id=data.get('revision_id', None), revision_nb=data['revision_number']) case _: raise Exception(f'Unknown browser: {data["browser_name"]}') + return state def _has_revision_id(self) -> bool: return self._revision_id is not None def _has_revision_number(self) -> bool: - return self._revision_number is not None + return self._revision_nb is not None @abstractmethod - def _fetch_revision_id(self) -> str: + def _fetch_missing_data(self): pass - @abstractmethod - def _fetch_revision_number(self) -> int: - pass + def _is_valid_revision_id(self, revision_id: str) -> bool: + """ + Checks if a revision id is valid. + A valid revision id is a 40 character long string containing only lowercase letters and numbers. + """ + return re.match(r'[a-z0-9]{40}', revision_id) is not None - @property - def revision_id(self) -> str: - if self._revision_id is None: - self.revision_id = self._fetch_revision_id() - return self._revision_id - - @revision_id.setter - def revision_id(self, value: str): - assert value is not None - assert re.match(r'[a-z0-9]{40}', value), f'\'{value}\' is not a valid revision id' - self._revision_id = value - - @property - def revision_number(self) -> int: - if self._revision_number is None: - self.revision_number = self._fetch_revision_number() - return self._revision_number - - @revision_number.setter - def revision_number(self, value: int): - assert value is not None - assert re.match(r'[0-9]{1,7}', str(value)), f'\'{value}\' is not a valid revision number' - self._revision_number = value - - def add_parent(self, new_parent): - if not self.is_parent(new_parent): - self.parents.append(new_parent) - if not new_parent.is_child(self): - new_parent.add_child(self) - - def add_child(self, new_child): - if not self.is_child(new_child): - self.children.append(new_child) - if not new_child.is_parent(self): - new_child.add_parent(self) - - def is_parent(self, parent): - return parent in self.parents - - def is_child(self, child): - return child in self.children + def _is_valid_revision_number(self, revision_number: int) -> bool: + """ + Checks if a revision number is valid. + A valid revision number is a positive integer. + """ + return re.match(r'[0-9]{1,7}', str(revision_number)) is not None def __str__(self): - return f'RevisionState(id: {self._revision_id}, number: {self._revision_number})' + return f'RevisionState(number: {self._revision_nb}, id: {self._revision_id})' def __repr__(self): - return f'RevisionState(id: {self._revision_id}, number: {self._revision_number})' + return f'RevisionState(number: {self._revision_nb}, id: {self._revision_id})' diff --git a/bci/version_control/states/revisions/chromium.py b/bci/version_control/states/revisions/chromium.py index e586e73..7939e95 100644 --- a/bci/version_control/states/revisions/chromium.py +++ b/bci/version_control/states/revisions/chromium.py @@ -1,39 +1,53 @@ +from typing import Optional + import requests + +from bci.database.mongo.mongodb import MongoDB from bci.version_control.revision_parser.chromium_parser import ChromiumRevisionParser from bci.version_control.states.revisions.base import BaseRevision -from bci.database.mongo.mongodb import MongoDB PARSER = ChromiumRevisionParser() class ChromiumRevision(BaseRevision): - - def __init__(self, revision_id: str = None, revision_number: int = None, parents=None, children=None): - super().__init__(revision_id, revision_number, parents=parents, children=children) + def __init__(self, revision_id: Optional[str] = None, revision_nb: Optional[int] = None): + super().__init__(revision_id, revision_nb) @property def browser_name(self): return 'chromium' - def has_online_binary(self): - cached_binary_available_online = MongoDB.has_binary_available_online('chromium', self) + def has_online_binary(self) -> bool: + cached_binary_available_online = MongoDB().has_binary_available_online('chromium', self) if cached_binary_available_online is not None: return cached_binary_available_online - url = f'https://www.googleapis.com/storage/v1/b/chromium-browser-snapshots/o/Linux_x64%2F{self._revision_number}%2Fchrome-linux.zip' + url = f'https://www.googleapis.com/storage/v1/b/chromium-browser-snapshots/o/Linux_x64%2F{self._revision_nb}%2Fchrome-linux.zip' req = requests.get(url) has_binary_online = req.status_code == 200 - MongoDB.store_binary_availability_online_cache('chromium', self, has_binary_online) + MongoDB().store_binary_availability_online_cache('chromium', self, has_binary_online) return has_binary_online def get_online_binary_url(self): - return "https://www.googleapis.com/download/storage/v1/b/chromium-browser-snapshots/o/%s%%2F%s%%2Fchrome-%s.zip?alt=media" % ('Linux_x64', self._revision_number, 'linux') - - def _fetch_revision_id(self) -> str: - if state := MongoDB.get_complete_state_dict_from_binary_availability_cache(self): - return state['revision_id'] - return PARSER.get_rev_id(self._revision_number) - - def _fetch_revision_number(self) -> int: - if state := MongoDB.get_complete_state_dict_from_binary_availability_cache(self): - return state['revision_number'] - return PARSER.get_rev_number(self._revision_id) + return ( + 'https://www.googleapis.com/download/storage/v1/b/chromium-browser-snapshots/o/%s%%2F%s%%2Fchrome-%s.zip?alt=media' + % ('Linux_x64', self._revision_nb, 'linux') + ) + + def _fetch_missing_data(self) -> None: + """ + States are initialized with either a revision id or revision number. + This method attempts to fetch other data to complete this state object. + """ + # First check if the missing data is available in the cache + if self._revision_id and self._revision_nb: + return + if state := MongoDB().get_complete_state_dict_from_binary_availability_cache(self): + if self._revision_id is None: + self._revision_id = state.get('revision_id', None) + if self._revision_nb is None: + self._revision_nb = state.get('revision_number', None) + # If not, fetch the missing data from the parser + if self._revision_id is None: + self._revision_id = PARSER.get_revision_id(self._revision_nb) + if self._revision_nb is None: + self._revision_nb = PARSER.get_revision_nb(self._revision_id) diff --git a/bci/version_control/states/revisions/firefox.py b/bci/version_control/states/revisions/firefox.py index f9b948a..f9eb831 100644 --- a/bci/version_control/states/revisions/firefox.py +++ b/bci/version_control/states/revisions/firefox.py @@ -1,41 +1,50 @@ +from typing import Optional + +from bci.database.mongo.revision_cache import RevisionCache from bci.util import request_json from bci.version_control.states.revisions.base import BaseRevision +from bci.version_control.states.state import State -BINARY_AVAILABILITY_URL = "https://distrinet.pages.gitlab.kuleuven.be/users/gertjan-franken/bughog-revision-metadata/firefox_binary_availability.json" -REVISION_NUMBER_MAPPING_URL = "https://distrinet.pages.gitlab.kuleuven.be/users/gertjan-franken/bughog-revision-metadata/firefox_revision_nb_to_id.json" +BINARY_AVAILABILITY_URL = 'https://distrinet.pages.gitlab.kuleuven.be/users/gertjan-franken/bughog-revision-metadata/firefox_binary_availability.json' +REVISION_NUMBER_MAPPING_URL = 'https://distrinet.pages.gitlab.kuleuven.be/users/gertjan-franken/bughog-revision-metadata/firefox_revision_nb_to_id.json' -BINARY_AVAILABILITY_MAPPING = request_json(BINARY_AVAILABILITY_URL)["data"] -REVISION_NUMBER_MAPPING = request_json(REVISION_NUMBER_MAPPING_URL)["data"] +BINARY_AVAILABILITY_MAPPING = request_json(BINARY_AVAILABILITY_URL)['data'] +REVISION_NUMBER_MAPPING = request_json(REVISION_NUMBER_MAPPING_URL)['data'] class FirefoxRevision(BaseRevision): - - def __init__(self, revision_id: str = None, revision_number: str = None, parents=None, children=None, version: int = None): - super().__init__(revision_id=revision_id, revision_number=revision_number, parents=parents, children=children) - self.version = version + def __init__( + self, revision_id: Optional[str] = None, revision_nb: Optional[int] = None, major_version: Optional[int] = None + ): + super().__init__(revision_id=revision_id, revision_nb=revision_nb) + self.major_version = major_version @property - def browser_name(self): + def browser_name(self) -> str: return 'firefox' def has_online_binary(self) -> bool: - if self._revision_id: - return self._revision_id in BINARY_AVAILABILITY_MAPPING - if self._revision_number: - return str(self._revision_number) in REVISION_NUMBER_MAPPING + return RevisionCache.firefox_has_binary_for(revision_nb=self.revision_nb, revision_id=self._revision_id) def get_online_binary_url(self) -> str: - binary_base_url = BINARY_AVAILABILITY_MAPPING[self.revision_id]["files_url"] - app_version = BINARY_AVAILABILITY_MAPPING[self.revision_id]["app_version"] - binary_url = f"{binary_base_url}firefox-{app_version}.en-US.linux-x86_64.tar.bz2" + result = RevisionCache.firefox_get_binary_info(self._revision_id) + binary_base_url = result['files_url'] + app_version = result['app_version'] + binary_url = f'{binary_base_url}firefox-{app_version}.en-US.linux-x86_64.tar.bz2' return binary_url - def _fetch_revision_id(self) -> str: - return REVISION_NUMBER_MAPPING.get(str(self._revision_number), None) - - def _fetch_revision_number(self) -> int: - binary_data = BINARY_AVAILABILITY_MAPPING.get(self._revision_id, None) - if binary_data is not None: - return binary_data.get('revision_number') - else: - return None + def get_previous_and_next_state_with_binary(self) -> tuple[State, State]: + previous_revision_nb, next_revision_nb = RevisionCache.firefox_get_previous_and_next_revision_nb_with_binary( + self.revision_nb + ) + + return ( + FirefoxRevision(revision_nb=previous_revision_nb) if previous_revision_nb else None, + FirefoxRevision(revision_nb=next_revision_nb) if next_revision_nb else None, + ) + + def _fetch_missing_data(self): + if self._revision_id is None: + self._revision_id = REVISION_NUMBER_MAPPING.get(str(self._revision_nb), None) + if self._revision_nb is None: + RevisionCache.firefox_get_revision_number(self._revision_id) diff --git a/bci/version_control/states/state.py b/bci/version_control/states/state.py index 31dc10a..586d379 100644 --- a/bci/version_control/states/state.py +++ b/bci/version_control/states/state.py @@ -1,34 +1,89 @@ from __future__ import annotations -from abc import abstractmethod, abstractproperty +from abc import abstractmethod +from dataclasses import dataclass +from enum import Enum + + +class StateCondition(Enum): + """ + The condition of a state. + """ + + # This state has been evaluated and the result is available. + COMPLETED = 0 + # The evaluation of this state has failed. + FAILED = 1 + # The evaluation of this state is in progress. + IN_PROGRESS = 2 + # The evaluation of this state has not started yet. + PENDING = 3 + # This state is not available. + UNAVAILABLE = 4 + + +@dataclass(frozen=True) +class StateResult: + requests: list[dict[str, str]] + request_vars: list[dict[str, str]] + log_vars: list[dict[str, str]] + is_dirty: bool + @property + def reproduced(self): + entry_if_reproduced = {'var': 'reproduced', 'val': 'OK'} + reproduced_in_req_vars = [entry for entry in self.request_vars if entry == entry_if_reproduced] != [] + reproduced_in_log_vars = [entry for entry in self.log_vars if entry == entry_if_reproduced] != [] + return reproduced_in_req_vars or reproduced_in_log_vars -class EvaluationResult: - BuildUnavailable = "build unavailable" - Error = "error" - Positive = "positive" - Negative = "negative" - Undefined = "undefined" + @staticmethod + def from_dict(data: dict, is_dirty: bool = False) -> StateResult: + return StateResult(data['requests'], data['req_vars'], data['log_vars'], is_dirty) class State: + def __init__(self): + self.condition = StateCondition.PENDING + self.result: StateResult + self.outcome: bool | None = None + + @property + @abstractmethod + def name(self) -> str: + pass - @abstractproperty - def name(self): + @property + @abstractmethod + def browser_name(self) -> str: pass - @abstractproperty - def browser_name(self): + @property + @abstractmethod + def type(self) -> str: pass + @property @abstractmethod - def to_dict(self): + def index(self) -> int: + """ + The index of the element in the sequence. + """ + pass + + @property + @abstractmethod + def revision_nb(self) -> int: + pass + + @abstractmethod + def to_dict(self) -> dict: pass @staticmethod def from_dict(data: dict) -> State: from bci.version_control.states.revisions.base import BaseRevision from bci.version_control.states.versions.base import BaseVersion + match data['type']: case 'revision': return BaseRevision.from_dict(data) @@ -45,49 +100,25 @@ def has_online_binary(self) -> bool: def get_online_binary_url(self) -> str: pass - def is_evaluation_target(self): - return self.evaluation_target - - def set_as_evaluation_target(self): - self.evaluation_target = True - - def set_evaluation_outcome(self, outcome: bool): - if outcome: - self.result = EvaluationResult.Positive + def has_available_binary(self) -> bool: + if self.condition == StateCondition.UNAVAILABLE: + return False else: - self.result = EvaluationResult.Negative + has_available_binary = self.has_online_binary() + if not has_available_binary: + self.condition = StateCondition.UNAVAILABLE + return has_available_binary - def set_evaluation_build_unavailable(self): - self.result = EvaluationResult.BuildUnavailable + def get_previous_and_next_state_with_binary(self) -> tuple[State, State]: + raise NotImplementedError(f'This function is not implemented for {self}') - def set_evaluation_error(self, error_message): - self.result = error_message + def __repr__(self) -> str: + return f'State(index={self.index})' - @property - def build_unavailable(self): - return self.result == EvaluationResult.BuildUnavailable + def __eq__(self, other: object) -> bool: + if not isinstance(other, State): + return False + return self.index == other.index - @property - def result_undefined(self): - return len(self.result) == 0 - - # @classmethod - # def create_state_list(cls, evaluation_targets, revision_numbers) -> list: - # states = [] - # ancestor_state = cls(revision_number=revision_numbers[0]) - # descendant_state = cls(revision_number=revision_numbers[len(revision_numbers) - 1]) - - # states.append(ancestor_state) - # prev_state = ancestor_state - # for i in range(1, len(revision_numbers) - 1): - # revision_number = revision_numbers[i] - # curr_state = cls(revision_number=revision_number) - # curr_state.add_parent(prev_state) - # states.append(curr_state) - # prev_state = curr_state - # if evaluation_targets is None or revision_number in evaluation_targets: - # curr_state.set_as_evaluation_target() - - # descendant_state.add_parent(prev_state) - # states.append(descendant_state) - # return states + def __hash__(self) -> int: + return hash((self.index, self.browser_name)) diff --git a/bci/version_control/states/versions/base.py b/bci/version_control/states/versions/base.py index 45d3636..9c58639 100644 --- a/bci/version_control/states/versions/base.py +++ b/bci/version_control/states/versions/base.py @@ -1,60 +1,73 @@ -from abc import abstractmethod, abstractproperty +from abc import abstractmethod from bci.version_control.states.state import State class BaseVersion(State): - def __init__(self, major_version: int): super().__init__() self.major_version = major_version - self._rev_nb = self._get_rev_nb() - self._rev_id = self._get_rev_id() + self._revision_nb = self._get_rev_nb() + self._revision_id = self._get_rev_id() @abstractmethod - def _get_rev_nb(self): + def _get_rev_nb(self) -> int: pass @abstractmethod - def _get_rev_id(self): + def _get_rev_id(self) -> str: pass @property - def name(self): + def name(self) -> str: return f'v_{self.major_version}' - @abstractproperty - def browser_name(self): + @property + @abstractmethod + def browser_name(self) -> str: pass + @property + def type(self) -> str: + return 'version' + + @property + def index(self) -> int: + return self.major_version + + @property + def revision_nb(self) -> int: + return self._revision_nb + def to_dict(self, make_complete: bool = True) -> dict: return { - 'type': 'version', + 'type': self.type, 'browser_name': self.browser_name, 'major_version': self.major_version, - 'revision_id': self._rev_id, - 'revision_number': self._rev_nb + 'revision_id': self._revision_id, + 'revision_number': self._revision_nb, } @staticmethod def from_dict(data: dict) -> State: - from bci.version_control.states.versions.chromium import \ - ChromiumVersion + from bci.version_control.states.versions.chromium import ChromiumVersion from bci.version_control.states.versions.firefox import FirefoxVersion + match data['browser_name']: case 'chromium': - return ChromiumVersion( - major_version=data['major_version'] - ) + state = ChromiumVersion(major_version=data['major_version']) case 'firefox': - return FirefoxVersion( - major_version=data['major_version'] - ) + state = FirefoxVersion(major_version=data['major_version']) case _: raise Exception(f'Unknown browser: {data["browser_name"]}') + return state + + @abstractmethod + def convert_to_revision(self) -> State: + pass def __str__(self): - return f'VersionState(version: {self.major_version}, rev: {self._rev_nb})' + return f'VersionState(version: {self.major_version}, rev: {self._revision_nb})' def __repr__(self): - return f'VersionState(version: {self.major_version}, rev: {self._rev_nb})' + return f'VersionState(version: {self.major_version}, rev: {self._revision_nb})' diff --git a/bci/version_control/states/versions/chromium.py b/bci/version_control/states/versions/chromium.py index 6814b3d..2374729 100644 --- a/bci/version_control/states/versions/chromium.py +++ b/bci/version_control/states/versions/chromium.py @@ -1,18 +1,19 @@ import requests -from bci.version_control.repository.online.chromium import get_release_revision_number, get_release_revision_id -from bci.version_control.states.versions.base import BaseVersion + from bci.database.mongo.mongodb import MongoDB +from bci.version_control.repository.online.chromium import get_release_revision_id, get_release_revision_number +from bci.version_control.states.revisions.chromium import ChromiumRevision +from bci.version_control.states.versions.base import BaseVersion class ChromiumVersion(BaseVersion): - def __init__(self, major_version: int): super().__init__(major_version) - def _get_rev_nb(self): + def _get_rev_nb(self) -> int: return get_release_revision_number(self.major_version) - def _get_rev_id(self): + def _get_rev_id(self) -> str: return get_release_revision_id(self.major_version) @property @@ -20,14 +21,20 @@ def browser_name(self): return 'chromium' def has_online_binary(self): - cached_binary_available_online = MongoDB.has_binary_available_online('chromium', self) + cached_binary_available_online = MongoDB().has_binary_available_online('chromium', self) if cached_binary_available_online is not None: return cached_binary_available_online - url = f'https://www.googleapis.com/storage/v1/b/chromium-browser-snapshots/o/Linux_x64%2F{self._rev_nb}%2Fchrome-linux.zip' + url = f'https://www.googleapis.com/storage/v1/b/chromium-browser-snapshots/o/Linux_x64%2F{self._revision_nb}%2Fchrome-linux.zip' req = requests.get(url) has_binary_online = req.status_code == 200 - MongoDB.store_binary_availability_online_cache('chromium', self, has_binary_online) + MongoDB().store_binary_availability_online_cache('chromium', self, has_binary_online) return has_binary_online def get_online_binary_url(self): - return "https://www.googleapis.com/download/storage/v1/b/chromium-browser-snapshots/o/%s%%2F%s%%2Fchrome-%s.zip?alt=media" % ('Linux_x64', self._rev_nb, 'linux') + return ( + 'https://www.googleapis.com/download/storage/v1/b/chromium-browser-snapshots/o/%s%%2F%s%%2Fchrome-%s.zip?alt=media' + % ('Linux_x64', self._revision_nb, 'linux') + ) + + def convert_to_revision(self) -> ChromiumRevision: + return ChromiumRevision(revision_nb=self._revision_nb) diff --git a/bci/version_control/states/versions/firefox.py b/bci/version_control/states/versions/firefox.py index ba719a4..67463f0 100644 --- a/bci/version_control/states/versions/firefox.py +++ b/bci/version_control/states/versions/firefox.py @@ -1,4 +1,5 @@ from bci.version_control.repository.online.firefox import get_release_revision_number, get_release_revision_id +from bci.version_control.states.revisions.firefox import FirefoxRevision from bci.version_control.states.versions.base import BaseVersion @@ -7,18 +8,21 @@ class FirefoxVersion(BaseVersion): def __init__(self, major_version: int): super().__init__(major_version) - def _get_rev_nb(self): + def _get_rev_nb(self) -> int: return get_release_revision_number(self.major_version) - def _get_rev_id(self): + def _get_rev_id(self) -> str: return get_release_revision_id(self.major_version) @property - def browser_name(self): + def browser_name(self) -> str: return 'firefox' - def has_online_binary(self): - return f'https://www.googleapis.com/storage/v1/b/chromium-browser-snapshots/o/Linux_x64%2F{self._rev_nb}%2Fchrome-linux.zip' + def has_online_binary(self) -> bool: + return True - def get_online_binary_url(self): + def get_online_binary_url(self) -> str: return f'https://ftp.mozilla.org/pub/firefox/releases/{self.major_version}.0/linux-x86_64/en-US/firefox-{self.major_version}.0.tar.bz2' + + def convert_to_revision(self) -> FirefoxRevision: + return FirefoxRevision(revision_nb=self._revision_nb) diff --git a/bci/web/clients.py b/bci/web/clients.py index 54e21fd..eee5d24 100644 --- a/bci/web/clients.py +++ b/bci/web/clients.py @@ -6,7 +6,7 @@ class Clients: __semaphore = threading.Semaphore() - __clients: dict[Server] = {} + __clients: dict[Server, dict | None] = {} @staticmethod def add_client(ws_client: Server): @@ -16,9 +16,7 @@ def add_client(ws_client: Server): @staticmethod def __remove_disconnected_clients(): with Clients.__semaphore: - Clients.__clients = { - k: v for k, v in Clients.__clients.items() if k.connected - } + Clients.__clients = {k: v for k, v in Clients.__clients.items() if k.connected} @staticmethod def associate_params(ws_client: Server, params: dict): @@ -35,7 +33,7 @@ def associate_project(ws_client: Server, project: str): with Clients.__semaphore: if not (params := Clients.__clients.get(ws_client, None)): params = {} - params["project"] = project + params['project'] = project Clients.__clients[ws_client] = params Clients.push_experiments(ws_client) @@ -48,10 +46,10 @@ def push_results(ws_client: Server): ws_client.send( json.dumps( { - "update": { - "plot_data": { - "revision_data": revision_data, - "version_data": version_data, + 'update': { + 'plot_data': { + 'revision_data': revision_data, + 'version_data': version_data, } } } @@ -65,21 +63,21 @@ def push_results_to_all(): Clients.push_results(ws_client) @staticmethod - def push_info(ws_client: Server, *requested_vars: list[str]): + def push_info(ws_client: Server, *requested_vars: str): from bci.main import Main as bci_api update = {} - all = not requested_vars or "all" in requested_vars - if "db_info" in requested_vars or all: - update["db_info"] = bci_api.get_database_info() - if "logs" in requested_vars or all: - update["logs"] = bci_api.get_logs() - if "state" in requested_vars or all: - update["state"] = bci_api.get_state() - ws_client.send(json.dumps({"update": update})) + all = not requested_vars or 'all' in requested_vars + if 'db_info' in requested_vars or all: + update['db_info'] = bci_api.get_database_info() + if 'logs' in requested_vars or all: + update['logs'] = bci_api.get_logs() + if 'state' in requested_vars or all: + update['state'] = bci_api.get_state() + ws_client.send(json.dumps({'update': update})) @staticmethod - def push_info_to_all(*requested_vars: list[str]): + def push_info_to_all(*requested_vars: str): Clients.__remove_disconnected_clients() for ws_client in Clients.__clients.keys(): Clients.push_info(ws_client, *requested_vars) @@ -88,10 +86,10 @@ def push_info_to_all(*requested_vars: list[str]): def push_experiments(ws_client: Server): from bci.main import Main as bci_api - project = Clients.__clients[ws_client].get("project", None) + project = Clients.__clients[ws_client].get('project', None) if project: experiments = bci_api.get_mech_groups_of_evaluation_framework('custom', project) - ws_client.send(json.dumps({"update": {"experiments": experiments}})) + ws_client.send(json.dumps({'update': {'experiments': experiments}})) @staticmethod def push_experiments_to_all(): diff --git a/bci/web/vue/package-lock.json b/bci/web/vue/package-lock.json index b31f6b2..1a1d035 100644 --- a/bci/web/vue/package-lock.json +++ b/bci/web/vue/package-lock.json @@ -36,10 +36,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/parser": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", - "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.9.tgz", + "integrity": "sha512-aI3jjAAO1fh7vY/pBGsn1i9LDbRP43+asrRlkPuTXW5yHXtd1NgTEMudbBoDDxrf1daEEfPJqR+JBMakzrR4Dg==", + "dependencies": { + "@babel/types": "^7.25.9" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -47,6 +66,18 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/types": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.9.tgz", + "integrity": "sha512-OwS2CM5KocvQ/k7dFJa8i5bNGJP0hXWfVCfDkqRFP1IreH1JDC7wG6eCYCi0+McbfT8OR/kNqsI0UU0xP9H6PQ==", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/android-arm": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", @@ -417,14 +448,14 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", "dev": true, "dependencies": { - "@jridgewell/set-array": "^1.0.1", + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" @@ -440,23 +471,23 @@ } }, "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.22", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", - "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -531,94 +562,95 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.19.tgz", - "integrity": "sha512-gj81785z0JNzRcU0Mq98E56e4ltO1yf8k5PQ+tV/7YHnbZkrM0fyFyuttnN8ngJZjbpofWE/m4qjKBiLl8Ju4w==", + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.12.tgz", + "integrity": "sha512-ISyBTRMmMYagUxhcpyEH0hpXRd/KqDU4ymofPgl2XAkY9ZhQ+h0ovEZJIiPop13UmR/54oA2cgMDjgroRelaEw==", "dependencies": { - "@babel/parser": "^7.23.9", - "@vue/shared": "3.4.19", + "@babel/parser": "^7.25.3", + "@vue/shared": "3.5.12", "entities": "^4.5.0", "estree-walker": "^2.0.2", - "source-map-js": "^1.0.2" + "source-map-js": "^1.2.0" } }, "node_modules/@vue/compiler-dom": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.19.tgz", - "integrity": "sha512-vm6+cogWrshjqEHTzIDCp72DKtea8Ry/QVpQRYoyTIg9k7QZDX6D8+HGURjtmatfgM8xgCFtJJaOlCaRYRK3QA==", + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.12.tgz", + "integrity": "sha512-9G6PbJ03uwxLHKQ3P42cMTi85lDRvGLB2rSGOiQqtXELat6uI4n8cNz9yjfVHRPIu+MsK6TE418Giruvgptckg==", "dependencies": { - "@vue/compiler-core": "3.4.19", - "@vue/shared": "3.4.19" + "@vue/compiler-core": "3.5.12", + "@vue/shared": "3.5.12" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.19.tgz", - "integrity": "sha512-LQ3U4SN0DlvV0xhr1lUsgLCYlwQfUfetyPxkKYu7dkfvx7g3ojrGAkw0AERLOKYXuAGnqFsEuytkdcComei3Yg==", - "dependencies": { - "@babel/parser": "^7.23.9", - "@vue/compiler-core": "3.4.19", - "@vue/compiler-dom": "3.4.19", - "@vue/compiler-ssr": "3.4.19", - "@vue/shared": "3.4.19", + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.12.tgz", + "integrity": "sha512-2k973OGo2JuAa5+ZlekuQJtitI5CgLMOwgl94BzMCsKZCX/xiqzJYzapl4opFogKHqwJk34vfsaKpfEhd1k5nw==", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/compiler-core": "3.5.12", + "@vue/compiler-dom": "3.5.12", + "@vue/compiler-ssr": "3.5.12", + "@vue/shared": "3.5.12", "estree-walker": "^2.0.2", - "magic-string": "^0.30.6", - "postcss": "^8.4.33", - "source-map-js": "^1.0.2" + "magic-string": "^0.30.11", + "postcss": "^8.4.47", + "source-map-js": "^1.2.0" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.19.tgz", - "integrity": "sha512-P0PLKC4+u4OMJ8sinba/5Z/iDT84uMRRlrWzadgLA69opCpI1gG4N55qDSC+dedwq2fJtzmGald05LWR5TFfLw==", + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.12.tgz", + "integrity": "sha512-eLwc7v6bfGBSM7wZOGPmRavSWzNFF6+PdRhE+VFJhNCgHiF8AM7ccoqcv5kBXA2eWUfigD7byekvf/JsOfKvPA==", "dependencies": { - "@vue/compiler-dom": "3.4.19", - "@vue/shared": "3.4.19" + "@vue/compiler-dom": "3.5.12", + "@vue/shared": "3.5.12" } }, "node_modules/@vue/reactivity": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.19.tgz", - "integrity": "sha512-+VcwrQvLZgEclGZRHx4O2XhyEEcKaBi50WbxdVItEezUf4fqRh838Ix6amWTdX0CNb/b6t3Gkz3eOebfcSt+UA==", + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.12.tgz", + "integrity": "sha512-UzaN3Da7xnJXdz4Okb/BGbAaomRHc3RdoWqTzlvd9+WBR5m3J39J1fGcHes7U3za0ruYn/iYy/a1euhMEHvTAg==", "dependencies": { - "@vue/shared": "3.4.19" + "@vue/shared": "3.5.12" } }, "node_modules/@vue/runtime-core": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.19.tgz", - "integrity": "sha512-/Z3tFwOrerJB/oyutmJGoYbuoadphDcJAd5jOuJE86THNZji9pYjZroQ2NFsZkTxOq0GJbb+s2kxTYToDiyZzw==", + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.12.tgz", + "integrity": "sha512-hrMUYV6tpocr3TL3Ad8DqxOdpDe4zuQY4HPY3X/VRh+L2myQO8MFXPAMarIOSGNu0bFAjh1yBkMPXZBqCk62Uw==", "dependencies": { - "@vue/reactivity": "3.4.19", - "@vue/shared": "3.4.19" + "@vue/reactivity": "3.5.12", + "@vue/shared": "3.5.12" } }, "node_modules/@vue/runtime-dom": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.19.tgz", - "integrity": "sha512-IyZzIDqfNCF0OyZOauL+F4yzjMPN2rPd8nhqPP2N1lBn3kYqJpPHHru+83Rkvo2lHz5mW+rEeIMEF9qY3PB94g==", + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.12.tgz", + "integrity": "sha512-q8VFxR9A2MRfBr6/55Q3umyoN7ya836FzRXajPB6/Vvuv0zOPL+qltd9rIMzG/DbRLAIlREmnLsplEF/kotXKA==", "dependencies": { - "@vue/runtime-core": "3.4.19", - "@vue/shared": "3.4.19", + "@vue/reactivity": "3.5.12", + "@vue/runtime-core": "3.5.12", + "@vue/shared": "3.5.12", "csstype": "^3.1.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.19.tgz", - "integrity": "sha512-eAj2p0c429RZyyhtMRnttjcSToch+kTWxFPHlzGMkR28ZbF1PDlTcmGmlDxccBuqNd9iOQ7xPRPAGgPVj+YpQw==", + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.12.tgz", + "integrity": "sha512-I3QoeDDeEPZm8yR28JtY+rk880Oqmj43hreIBVTicisFTx/Dl7JpG72g/X7YF8hnQD3IFhkky5i2bPonwrTVPg==", "dependencies": { - "@vue/compiler-ssr": "3.4.19", - "@vue/shared": "3.4.19" + "@vue/compiler-ssr": "3.5.12", + "@vue/shared": "3.5.12" }, "peerDependencies": { - "vue": "3.4.19" + "vue": "3.5.12" } }, "node_modules/@vue/shared": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.19.tgz", - "integrity": "sha512-/KliRRHMF6LoiThEy+4c1Z4KB/gbPrGjWwJR+crg2otgrf/egKzRaCPvJ51S5oetgsgXLfc4Rm5ZgrKHZrtMSw==" + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.12.tgz", + "integrity": "sha512-L2RPSAwUFbgZH20etwrXyVyCBu9OxRSi8T/38QsvnkJyvq2LufW2lDCOzm7t/U9C1mkhJGWYfCuFBCmIuNivrg==" }, "node_modules/@vueform/slider": { "version": "2.1.10", @@ -626,14 +658,14 @@ "integrity": "sha512-L2G3Ju51Yq6yWF2wzYYsicUUaH56kL1QKGVtimUVHT1K1ADcRT94xVyIeJpS0klliVEeF6iMZFbdXtHq8AsDHw==" }, "node_modules/ace-builds": { - "version": "1.35.2", - "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.35.2.tgz", - "integrity": "sha512-06d00u4jDZx+ieI0jLlgy/uefx8kcgz7lhI0mCIFEu8NVWirH00U5IEP7tePHy4sjPsRcJUH4VbJZacoit2Hng==" + "version": "1.36.3", + "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.36.3.tgz", + "integrity": "sha512-YcdwV2IIaJSfjkWAR1NEYN5IxBiXefTgwXsJ//UlaFrjXDX5hQpvPFvEePHz2ZBUfvO54RjHeRUQGX8MS5HaMQ==" }, "node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true, "engines": { "node": ">=12" @@ -685,9 +717,9 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/autoprefixer": { - "version": "10.4.17", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz", - "integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==", + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", "dev": true, "funding": [ { @@ -704,11 +736,11 @@ } ], "dependencies": { - "browserslist": "^4.22.2", - "caniuse-lite": "^1.0.30001578", + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", + "picocolors": "^1.0.1", "postcss-value-parser": "^4.2.0" }, "bin": { @@ -722,9 +754,9 @@ } }, "node_modules/axios": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", - "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -738,12 +770,15 @@ "dev": true }, "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/brace-expansion": { @@ -768,9 +803,9 @@ } }, "node_modules/browserslist": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", - "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", + "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", "dev": true, "funding": [ { @@ -787,10 +822,10 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001587", - "electron-to-chromium": "^1.4.668", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" + "caniuse-lite": "^1.0.30001669", + "electron-to-chromium": "^1.5.41", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" @@ -809,9 +844,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001588", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001588.tgz", - "integrity": "sha512-+hVY9jE44uKLkH0SrUTqxjxqNTOWHsbnQDIKjwkZ3lNTzUUVdBLBGXtj/q5Mp5u98r3droaZAewQuEDzjQdZlQ==", + "version": "1.0.30001669", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001669.tgz", + "integrity": "sha512-DlWzFDJqstqtIVx1zeSpIMLjunf5SmwOw0N2Ck/QSQdS8PLS4+9HrLaYei4w8BIAL7IB/UEDu889d8vhCTPA0w==", "dev": true, "funding": [ { @@ -960,9 +995,9 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.4.677", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.677.tgz", - "integrity": "sha512-erDa3CaDzwJOpyvfKhOiJjBVNnMM0qxHq47RheVVwsSQrgBA9ZSGV9kdaOfZDPXcHzhG7lBxhj6A7KvfLJBd6Q==", + "version": "1.5.43", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.43.tgz", + "integrity": "sha512-NxnmFBHDl5Sachd2P46O7UJiMaMHMLSofoIWVJq3mj8NJgG0umiSeljAVP9lGzjI0UDLJJ5jjoGjcrB8RSbjLQ==", "dev": true }, "node_modules/emoji-regex": { @@ -1020,9 +1055,9 @@ } }, "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "engines": { "node": ">=6" @@ -1092,9 +1127,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", "funding": [ { "type": "individual", @@ -1111,9 +1146,9 @@ } }, "node_modules/foreground-child": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", - "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", "dev": true, "dependencies": { "cross-spawn": "^7.0.0", @@ -1127,9 +1162,9 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -1176,23 +1211,21 @@ } }, "node_modules/glob": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", - "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dev": true, "dependencies": { "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -1210,9 +1243,9 @@ } }, "node_modules/hasown": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz", - "integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, "dependencies": { "function-bind": "^1.1.2" @@ -1234,12 +1267,15 @@ } }, "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", "dev": true, "dependencies": { - "hasown": "^2.0.0" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -1291,16 +1327,13 @@ "dev": true }, "node_modules/jackspeak": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", - "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, "dependencies": { "@isaacs/cliui": "^8.0.2" }, - "engines": { - "node": ">=14" - }, "funding": { "url": "https://github.com/sponsors/isaacs" }, @@ -1309,9 +1342,9 @@ } }, "node_modules/jiti": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", - "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "version": "1.21.6", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", + "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", "dev": true, "bin": { "jiti": "bin/jiti.js" @@ -1333,23 +1366,17 @@ "dev": true }, "node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", - "dev": true, - "engines": { - "node": "14 || >=16.14" - } + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, "node_modules/magic-string": { - "version": "0.30.7", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", - "integrity": "sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==", + "version": "0.30.12", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", + "integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "engines": { - "node": ">=12" + "@jridgewell/sourcemap-codec": "^1.5.0" } }, "node_modules/merge2": { @@ -1362,12 +1389,12 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -1402,9 +1429,9 @@ } }, "node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, "dependencies": { "brace-expansion": "^2.0.1" @@ -1417,9 +1444,9 @@ } }, "node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "dev": true, "engines": { "node": ">=16 || 14 >=14.17" @@ -1454,9 +1481,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", "dev": true }, "node_modules/normalize-path": { @@ -1537,6 +1564,12 @@ } } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -1553,25 +1586,25 @@ "dev": true }, "node_modules/path-scurry": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", - "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, "dependencies": { - "lru-cache": "^9.1.1 || ^10.0.0", + "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, "node_modules/picomatch": { "version": "2.3.1", @@ -1604,9 +1637,9 @@ } }, "node_modules/postcss": { - "version": "8.4.35", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", - "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "funding": [ { "type": "opencollective", @@ -1623,8 +1656,8 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -1702,9 +1735,9 @@ } }, "node_modules/postcss-load-config/node_modules/lilconfig": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", - "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", + "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", "dev": true, "engines": { "node": ">=14" @@ -1714,28 +1747,34 @@ } }, "node_modules/postcss-nested": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", - "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "dependencies": { - "postcss-selector-parser": "^6.0.11" + "postcss-selector-parser": "^6.1.1" }, "engines": { "node": ">=12.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, "peerDependencies": { "postcss": "^8.2.14" } }, "node_modules/postcss-selector-parser": { - "version": "6.0.15", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz", - "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "dev": true, "dependencies": { "cssesc": "^3.0.0", @@ -1897,9 +1936,9 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "engines": { "node": ">=0.10.0" } @@ -2035,9 +2074,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz", - "integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==", + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.14.tgz", + "integrity": "sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==", "dev": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -2048,7 +2087,7 @@ "fast-glob": "^3.3.0", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.19.1", + "jiti": "^1.21.0", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", @@ -2111,9 +2150,9 @@ "dev": true }, "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", "dev": true, "funding": [ { @@ -2130,8 +2169,8 @@ } ], "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.2.0", + "picocolors": "^1.1.0" }, "bin": { "update-browserslist-db": "cli.js" @@ -2202,15 +2241,15 @@ } }, "node_modules/vue": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.19.tgz", - "integrity": "sha512-W/7Fc9KUkajFU8dBeDluM4sRGc/aa4YJnOYck8dkjgZoXtVsn3OeTGni66FV1l3+nvPA7VBFYtPioaGKUmEADw==", + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.12.tgz", + "integrity": "sha512-CLVZtXtn2ItBIi/zHZ0Sg1Xkb7+PU32bJJ8Bmy7ts3jxXTcbfsEfBivFYYWz1Hur+lalqGAh65Coin0r+HRUfg==", "dependencies": { - "@vue/compiler-dom": "3.4.19", - "@vue/compiler-sfc": "3.4.19", - "@vue/runtime-dom": "3.4.19", - "@vue/server-renderer": "3.4.19", - "@vue/shared": "3.4.19" + "@vue/compiler-dom": "3.5.12", + "@vue/compiler-sfc": "3.5.12", + "@vue/runtime-dom": "3.5.12", + "@vue/server-renderer": "3.5.12", + "@vue/shared": "3.5.12" }, "peerDependencies": { "typescript": "*" @@ -2222,9 +2261,9 @@ } }, "node_modules/vue-multiselect": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/vue-multiselect/-/vue-multiselect-2.1.8.tgz", - "integrity": "sha512-bgpvWZlT4EiUUCcwLAR655LdiifeqF62BDL2TLVddKfS/NcdIYVlvOr456N7GQIlBFNbb7vHfq+qOl8mpGAOJw==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vue-multiselect/-/vue-multiselect-2.1.9.tgz", + "integrity": "sha512-nGEppmzhQQT2iDz4cl+ZCX3BpeNhygK50zWFTIRS+r7K7i61uWXJWSioMuf+V/161EPQjexI8NaEBdUlF3dp+g==", "engines": { "node": ">= 4.0.0", "npm": ">= 3.0.0" @@ -2337,10 +2376,13 @@ } }, "node_modules/yaml": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", - "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", + "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", "dev": true, + "bin": { + "yaml": "bin.mjs" + }, "engines": { "node": ">= 14" } diff --git a/bci/web/vue/src/App.vue b/bci/web/vue/src/App.vue index b672aa3..cac9425 100644 --- a/bci/web/vue/src/App.vue +++ b/bci/web/vue/src/App.vue @@ -7,6 +7,7 @@ import PocEditor from "./components/poc-editor.vue" import SectionHeader from "./components/section-header.vue"; import Slider from '@vueform/slider' import Tooltip from "./components/tooltip.vue"; +import EvaluationStatus from './components/evaluation_status.vue'; export default { components: { Gantt, @@ -14,6 +15,7 @@ export default { SectionHeader, Slider, Tooltip, + EvaluationStatus, }, data() { return { @@ -498,13 +500,13 @@ export default {
-
- -
-
+
+
+ +
@@ -513,20 +515,8 @@ export default {
-
    -
  • Status: Running ✅
  • -
  • - Status: -
    Stopping... ⌛
    -
  • -
  • - Status: -
    Idle
    -
    (all binaries evaluated)
    -
    (stopped by user)
    -
    🛑
    -
  • -
+ +
    @@ -615,16 +605,16 @@ export default {
    - - + value="bgb_sequence" :disabled="this.eval_params.only_release_revisions"> + +
    - - - + + +
    diff --git a/bci/web/vue/src/components/evaluation_status.vue b/bci/web/vue/src/components/evaluation_status.vue new file mode 100644 index 0000000..fdd54c9 --- /dev/null +++ b/bci/web/vue/src/components/evaluation_status.vue @@ -0,0 +1,44 @@ + + + diff --git a/bci/web/vue/src/components/tooltip.vue b/bci/web/vue/src/components/tooltip.vue index 2ee94b1..d2dabfd 100644 --- a/bci/web/vue/src/components/tooltip.vue +++ b/bci/web/vue/src/components/tooltip.vue @@ -3,11 +3,11 @@ data() { return { tooltips: { - "bin_seq": { + "bgb_sequence": { "tooltip": "Binaries are selected uniformly over the specified evaluation range. Experiment outcomes do not influence the next binary to be evaluated." }, - "bin_search": { - "tooltip": "Perform a search to identify either an introducing or fixing revision. This should only be performed within a range where one shift in reproducibility has been observed." + "bgb_search": { + "tooltip": "Perform a search to identify introducing and fixing revision." }, "comp_search": { "tooltip": "Combines the two strategies above. First, binaries are selected uniformly over the evaluation range, until the sequence limit is reached. Then, for each shift in reproducibility that can be observed, a search is conducted to identify the introducing or fixing binary." diff --git a/bci/worker.py b/bci/worker.py index 2f1a44c..a18928f 100644 --- a/bci/worker.py +++ b/bci/worker.py @@ -7,43 +7,32 @@ from bci.evaluations.custom.custom_evaluation import CustomEvaluationFramework from bci.evaluations.logic import WorkerParameters -logger = logging.getLogger(__name__) +# This logger argument is set explicitly so when this file is ran as a script, it will still use the logger configuration +logger = logging.getLogger('bci.worker') def run(params: WorkerParameters): # Only perform configuration steps for separate workers if __name__ == '__main__': - Loggers.configure_loggers() - MongoDB.connect(params.database_connection_params) + MongoDB().connect(params.database_connection_params) # click passes options with multiple=True as a tuple, so we convert it to a list # browser_cli_options = list(browser_cli_options) - evaluation_framework = get_evaluation_framework(params) + evaluation_framework = CustomEvaluationFramework() # browser_build, repo_state = get_browser_build_and_repo_state(params) evaluation_framework.evaluate(params) -def get_evaluation_framework(params: WorkerParameters): - # TODO: we always select custom now, but still have to clean this up - return CustomEvaluationFramework() - # if params.evaluation_configuration.evaluation_framework == 'samesite': - # return SameSiteEvaluationFramework() - # elif params.evaluation_configuration.evaluation_framework == 'custom': - # return CustomEvaluationFramework() - # elif params.evaluation_configuration.evaluation_framework == 'xsleaks': - # return XSLeaksEvaluation() - # else: - # raise AttributeError(f'Unknown framework name \'{params.evaluation_configuration.evaluation_framework}\'') - - if __name__ == '__main__': + Loggers.configure_loggers() if len(sys.argv) < 2: logger.info('Worker did not receive any arguments.') os._exit(0) args = sys.argv[1] params = WorkerParameters.deserialize(args) + logger.info('Worker started') run(params) logger.info('Worker finished, exiting...') os._exit(0) diff --git a/config/.env.example b/config/.env.example index f4fa313..680bbbb 100644 --- a/config/.env.example +++ b/config/.env.example @@ -1,5 +1,9 @@ # Copy this file to config/.env en change it there if you would like to change your configuration. +# Cache parameters +# All binaries will be cached in the active MongoDB (either a local Docker container, or the one configured below). +BCI_BINARY_CACHE_LIMIT= + # Database parameters BCI_MONGO_HOST= BCI_MONGO_USERNAME= diff --git a/pyproject.toml b/pyproject.toml index 00db492..87866e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ exclude = [ ] # Same as Black. -line-length = 88 +line-length = 120 indent-width = 4 # Assume Python 3.8 @@ -58,8 +58,7 @@ unfixable = ["B"] dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" [tool.ruff.format] -# Like Black, use double quotes for strings. -# quote-style = "single" +quote-style = "single" # Like Black, indent with spaces, rather than tabs. indent-style = "space" diff --git a/requirements.in b/requirements.in index 8ed5b9d..b7055ad 100644 --- a/requirements.in +++ b/requirements.in @@ -3,6 +3,5 @@ Flask flask-sock flatten-dict gunicorn -mitmproxy pymongo requests diff --git a/requirements.txt b/requirements.txt index 8b5c44d..d2968cc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,34 +4,15 @@ # # pip-compile requirements.in # -aioquic==1.2.0 - # via mitmproxy -asgiref==3.8.1 - # via mitmproxy -attrs==23.2.0 - # via service-identity blinker==1.8.2 # via flask -brotli==1.1.0 - # via mitmproxy -certifi==2024.7.4 - # via - # aioquic - # mitmproxy - # requests -cffi==1.16.0 - # via cryptography -charset-normalizer==3.3.2 +certifi==2024.8.30 + # via requests +charset-normalizer==3.4.0 # via requests click==8.1.7 # via flask -cryptography==43.0.1 - # via - # aioquic - # mitmproxy - # pyopenssl - # service-identity -dnspython==2.6.1 +dnspython==2.7.0 # via pymongo docker==7.1.0 # via -r requirements.in @@ -39,107 +20,41 @@ flask==3.0.3 # via # -r requirements.in # flask-sock - # mitmproxy flask-sock==0.7.0 # via -r requirements.in flatten-dict==0.4.2 # via -r requirements.in -gunicorn==22.0.0 +gunicorn==23.0.0 # via -r requirements.in h11==0.14.0 - # via - # mitmproxy - # wsproto -h2==4.1.0 - # via mitmproxy -hpack==4.0.0 - # via h2 -hyperframe==6.0.1 - # via - # h2 - # mitmproxy -idna==3.7 + # via wsproto +idna==3.10 # via requests itsdangerous==2.2.0 # via flask jinja2==3.1.4 # via flask -kaitaistruct==0.10 - # via mitmproxy -ldap3==2.9.1 - # via mitmproxy -markupsafe==2.1.5 +markupsafe==3.0.2 # via # jinja2 # werkzeug -mitmproxy==10.4.2 - # via -r requirements.in -mitmproxy-rs==0.6.3 - # via mitmproxy -msgpack==1.0.8 - # via mitmproxy packaging==24.1 # via gunicorn -passlib==1.7.4 - # via mitmproxy -protobuf==5.27.3 - # via mitmproxy -publicsuffix2==2.20191221 - # via mitmproxy -pyasn1==0.6.0 - # via - # ldap3 - # pyasn1-modules - # service-identity -pyasn1-modules==0.4.0 - # via service-identity -pycparser==2.22 - # via cffi -pylsqpack==0.3.18 - # via aioquic -pymongo==4.8.0 +pymongo==4.10.1 # via -r requirements.in -pyopenssl==24.2.1 - # via - # aioquic - # mitmproxy -pyparsing==3.1.2 - # via mitmproxy -pyperclip==1.9.0 - # via mitmproxy requests==2.32.3 # via # -r requirements.in # docker -ruamel-yaml==0.18.6 - # via mitmproxy -ruamel-yaml-clib==0.2.8 - # via ruamel-yaml -service-identity==24.1.0 - # via aioquic -simple-websocket==1.0.0 +simple-websocket==1.1.0 # via flask-sock six==1.16.0 # via flatten-dict -sortedcontainers==2.4.0 - # via mitmproxy -tornado==6.4.1 - # via mitmproxy -typing-extensions==4.12.2 - # via urwid -urllib3==2.2.2 +urllib3==2.2.3 # via # docker # requests -urwid==2.6.15 - # via mitmproxy -wcwidth==0.2.13 - # via urwid -werkzeug==3.0.3 +werkzeug==3.0.4 # via flask wsproto==1.2.0 - # via - # mitmproxy - # simple-websocket -zstandard==0.23.0 - # via mitmproxy + # via simple-websocket diff --git a/requirements_dev.txt b/requirements_dev.txt index 2c7e5b7..c9143ac 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -4,50 +4,28 @@ # # pip-compile requirements_dev.in # -aioquic==1.2.0 - # via - # -r requirements.txt - # mitmproxy anybadge==1.14.0 # via -r requirements_dev.in -asgiref==3.8.1 - # via - # -r requirements.txt - # mitmproxy -astroid==3.2.4 +astroid==3.3.5 # via pylint -attrs==23.2.0 - # via - # -r requirements.txt - # service-identity autopep8==2.3.1 # via -r requirements_dev.in blinker==1.8.2 # via # -r requirements.txt # flask -boto3==1.34.152 +boto3==1.35.45 # via -r requirements_dev.in -botocore==1.34.152 +botocore==1.35.45 # via # -r requirements_dev.in # boto3 # s3transfer -brotli==1.1.0 - # via - # -r requirements.txt - # mitmproxy -certifi==2024.7.4 +certifi==2024.8.30 # via # -r requirements.txt - # aioquic - # mitmproxy # requests -cffi==1.16.0 - # via - # -r requirements.txt - # cryptography -charset-normalizer==3.3.2 +charset-normalizer==3.4.0 # via # -r requirements.txt # requests @@ -55,28 +33,21 @@ click==8.1.7 # via # -r requirements.txt # flask -coverage[toml]==7.6.0 +coverage[toml]==7.6.4 # via # -r requirements_dev.in # pytest-cov -cryptography==43.0.1 - # via - # -r requirements.txt - # aioquic - # mitmproxy - # pyopenssl - # service-identity -debugpy==1.8.2 +debugpy==1.8.7 # via -r requirements_dev.in -dill==0.3.8 +dill==0.3.9 # via pylint -dnspython==2.6.1 +dnspython==2.7.0 # via # -r requirements.txt # pymongo docker==7.1.0 # via -r requirements.txt -flake8==7.1.0 +flake8==7.1.1 # via # -r requirements_dev.in # pytest-flake8 @@ -84,32 +55,17 @@ flask==3.0.3 # via # -r requirements.txt # flask-sock - # mitmproxy flask-sock==0.7.0 # via -r requirements.txt flatten-dict==0.4.2 # via -r requirements.txt -gunicorn==22.0.0 +gunicorn==23.0.0 # via -r requirements.txt h11==0.14.0 # via # -r requirements.txt - # mitmproxy # wsproto -h2==4.1.0 - # via - # -r requirements.txt - # mitmproxy -hpack==4.0.0 - # via - # -r requirements.txt - # h2 -hyperframe==6.0.1 - # via - # -r requirements.txt - # h2 - # mitmproxy -idna==3.7 +idna==3.10 # via # -r requirements.txt # requests @@ -131,15 +87,7 @@ jmespath==1.0.1 # via # boto3 # botocore -kaitaistruct==0.10 - # via - # -r requirements.txt - # mitmproxy -ldap3==2.9.1 - # via - # -r requirements.txt - # mitmproxy -markupsafe==2.1.5 +markupsafe==3.0.2 # via # -r requirements.txt # jinja2 @@ -148,80 +96,27 @@ mccabe==0.7.0 # via # flake8 # pylint -mitmproxy==10.4.2 - # via -r requirements.txt -mitmproxy-rs==0.6.3 - # via - # -r requirements.txt - # mitmproxy -msgpack==1.0.8 - # via - # -r requirements.txt - # mitmproxy packaging==24.1 # via # -r requirements.txt # anybadge # gunicorn # pytest -passlib==1.7.4 - # via - # -r requirements.txt - # mitmproxy -platformdirs==4.2.2 +platformdirs==4.3.6 # via pylint pluggy==1.5.0 # via pytest -protobuf==5.27.3 - # via - # -r requirements.txt - # mitmproxy -publicsuffix2==2.20191221 - # via - # -r requirements.txt - # mitmproxy -pyasn1==0.6.0 - # via - # -r requirements.txt - # ldap3 - # pyasn1-modules - # service-identity -pyasn1-modules==0.4.0 - # via - # -r requirements.txt - # service-identity -pycodestyle==2.12.0 +pycodestyle==2.12.1 # via # autopep8 # flake8 -pycparser==2.22 - # via - # -r requirements.txt - # cffi pyflakes==3.2.0 # via flake8 -pylint==3.2.6 +pylint==3.3.1 # via -r requirements_dev.in -pylsqpack==0.3.18 - # via - # -r requirements.txt - # aioquic -pymongo==4.8.0 +pymongo==4.10.1 # via -r requirements.txt -pyopenssl==24.2.1 - # via - # -r requirements.txt - # aioquic - # mitmproxy -pyparsing==3.1.2 - # via - # -r requirements.txt - # mitmproxy -pyperclip==1.9.0 - # via - # -r requirements.txt - # mitmproxy -pytest==8.3.2 +pytest==8.3.3 # via # -r requirements_dev.in # pytest-cov @@ -236,21 +131,9 @@ requests==2.32.3 # via # -r requirements.txt # docker -ruamel-yaml==0.18.6 - # via - # -r requirements.txt - # mitmproxy -ruamel-yaml-clib==0.2.8 - # via - # -r requirements.txt - # ruamel-yaml -s3transfer==0.10.2 +s3transfer==0.10.3 # via boto3 -service-identity==24.1.0 - # via - # -r requirements.txt - # aioquic -simple-websocket==1.0.0 +simple-websocket==1.1.0 # via # -r requirements.txt # flask-sock @@ -259,44 +142,19 @@ six==1.16.0 # -r requirements.txt # flatten-dict # python-dateutil -sortedcontainers==2.4.0 - # via - # -r requirements.txt - # mitmproxy -tomlkit==0.13.0 +tomlkit==0.13.2 # via pylint -tornado==6.4.1 - # via - # -r requirements.txt - # mitmproxy -typing-extensions==4.12.2 - # via - # -r requirements.txt - # urwid -urllib3==2.2.2 +urllib3==2.2.3 # via # -r requirements.txt # botocore # docker # requests -urwid==2.6.15 - # via - # -r requirements.txt - # mitmproxy -wcwidth==0.2.13 - # via - # -r requirements.txt - # urwid -werkzeug==3.0.3 +werkzeug==3.0.4 # via # -r requirements.txt # flask wsproto==1.2.0 # via # -r requirements.txt - # mitmproxy # simple-websocket -zstandard==0.23.0 - # via - # -r requirements.txt - # mitmproxy diff --git a/scripts/boot/manage_certs.sh b/scripts/boot/manage_certs.sh index df02e13..087e73f 100755 --- a/scripts/boot/manage_certs.sh +++ b/scripts/boot/manage_certs.sh @@ -4,6 +4,7 @@ # Chromium mkdir -p $HOME/.pki/nssdb && \ -certutil -d sql:$HOME/.pki/nssdb -A -t TC -n bughog_CA -i /etc/nginx/ssl/certs/bughog_CA.crt && \ +certutil -d sql:$HOME/.pki/nssdb -A -t TC -n bughog_CA -i /etc/nginx/ssl/certs/bughog_CA.crt # Firefox # Certificates are added to the generated profiles in ./bci/browser/configuration/firefox.py + diff --git a/scripts/chromium/google-chrome-btpc b/scripts/chromium/google-chrome-btpc deleted file mode 100644 index 37c9b17..0000000 --- a/scripts/chromium/google-chrome-btpc +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -arg="$*" -data_dir=${arg##--*--user-data-dir=} -data_dir=${data_dir%% *,} -echo "$data_dir" >> /tmp/script_log -cp /tmp/59_btpc/Default/Preferences $data_dir/Default/Preferences - -new_arg="--no-sandbox --disable-gpu --use-fake-ui-for-media-stream $arg" -echo $new_arg >> /tmp/script_log -/usr/bin/google-chrome-proxy $new_arg \ No newline at end of file diff --git a/scripts/chromium/google-chrome-btpc-46 b/scripts/chromium/google-chrome-btpc-46 deleted file mode 100644 index 067aa88..0000000 --- a/scripts/chromium/google-chrome-btpc-46 +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -arg="$*" -data_dir=${arg##--*--user-data-dir=} -data_dir=${data_dir%% *,} -echo "$data_dir" >> /tmp/script_log -cp /app/browser/profiles/chromium/46_btpc/Default/Preferences $data_dir/Default/Preferences - -new_arg="--no-sandbox --disable-gpu --use-fake-ui-for-media-stream $arg" -echo $new_arg >> /tmp/script_log -/usr/bin/google-chrome-proxy $new_arg diff --git a/scripts/chromium/google-chrome-default b/scripts/chromium/google-chrome-default deleted file mode 100644 index d0ba5b4..0000000 --- a/scripts/chromium/google-chrome-default +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -/usr/bin/google-chrome-proxy --no-sandbox --disable-gpu --use-fake-ui-for-media-stream $* \ No newline at end of file diff --git a/scripts/chromium/google-chrome-extension b/scripts/chromium/google-chrome-extension deleted file mode 100644 index f37301c..0000000 --- a/scripts/chromium/google-chrome-extension +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -arg="$*" -new_arg="--no-sandbox --disable-gpu --use-fake-ui-for-media-stream --load-extension=%s $arg" -echo $new_arg >> /tmp/script_log -/usr/bin/google-chrome-proxy $new_arg \ No newline at end of file diff --git a/test/http_collector/test_collector.py b/test/http_collector/test_collector.py index 57bb5b6..2cc477f 100644 --- a/test/http_collector/test_collector.py +++ b/test/http_collector/test_collector.py @@ -3,7 +3,7 @@ import requests -from bci.evaluations.collector import Collector, Type +from bci.evaluations.collectors.collector import Collector, Type class TestCollector(unittest.TestCase): diff --git a/test/sequence/test_biggest_gap_bisection_search.py b/test/sequence/test_biggest_gap_bisection_search.py new file mode 100644 index 0000000..7c110d3 --- /dev/null +++ b/test/sequence/test_biggest_gap_bisection_search.py @@ -0,0 +1,81 @@ +import unittest + +from bci.search_strategy.bgb_search import BiggestGapBisectionSearch +from bci.search_strategy.sequence_strategy import SequenceFinished +from test.sequence.test_sequence_strategy import TestSequenceStrategy as helper + + +class TestBiggestGapBisectionSearch(unittest.TestCase): + + def test_sbg_search_always_available_search(self): + state_factory = helper.create_state_factory( + helper.always_has_binary, + outcome_func=lambda x: True if x < 50 else False) + sequence = BiggestGapBisectionSearch(state_factory) + index_sequence = [sequence.next().index for _ in range(8)] + assert index_sequence == [0, 99, 49, 74, 61, 55, 52, 50] + self.assertRaises(SequenceFinished, sequence.next) + + def test_sbg_search_even_available_search(self): + state_factory = helper.create_state_factory( + helper.only_has_binaries_for_even, + outcome_func=lambda x: True if x < 35 else False) + sequence = BiggestGapBisectionSearch(state_factory) + + assert sequence.next().index == 0 + assert [state.index for state in sequence._completed_states] == [0] + assert sequence._unavailability_gap_pairs == set() + + while True: + try: + sequence.next() + except SequenceFinished: + break + + assert ([state.index for state in sequence._completed_states] + == [0, 24, 30, 32, 34, 36, 48, 98]) + + self.assertRaises(SequenceFinished, sequence.next) + assert {(first.index, last.index) for (first, last) in sequence._unavailability_gap_pairs} == {(34, 36)} + + + def test_sbg_search_few_available_search(self): + state_factory = helper.create_state_factory( + helper.has_very_few_binaries, + outcome_func=lambda x: True if x < 35 else False) + sequence = BiggestGapBisectionSearch(state_factory) + + assert sequence.next().index == 0 + assert [state.index for state in sequence._completed_states] == [0] + assert sequence._unavailability_gap_pairs == set() + + assert sequence.next().index == 99 + assert [state.index for state in sequence._completed_states] == [0, 99] + + assert sequence.next().index == 44 + assert [state.index for state in sequence._completed_states] == [0, 44, 99] + + assert sequence.next().index == 22 + assert [state.index for state in sequence._completed_states] == [0, 22, 44, 99] + + assert sequence.next().index == 33 + assert [state.index for state in sequence._completed_states] == [0, 22, 33, 44, 99] + + self.assertRaises(SequenceFinished, sequence.next) + assert {(first.index, last.index) for (first, last) in sequence._unavailability_gap_pairs} == {(33, 44)} + + def test_sbg_search_few_available_search_complex(self): + state_factory = helper.create_state_factory( + helper.only_has_binaries_for_even, + evaluated_indexes=[0, 12, 22, 34, 44, 56, 66, 78, 88, 98], + outcome_func=lambda x: True if x < 35 or 66 < x else False) + sequence = BiggestGapBisectionSearch(state_factory) + + while True: + try: + sequence.next() + except SequenceFinished: + break + + assert ([state.index for state in sequence._completed_states] + == [0, 12, 22, 34, 36, 38, 44, 56, 66, 68, 72, 78, 88, 98]) diff --git a/test/sequence/test_biggest_gap_bisection_sequence.py b/test/sequence/test_biggest_gap_bisection_sequence.py new file mode 100644 index 0000000..c476ecd --- /dev/null +++ b/test/sequence/test_biggest_gap_bisection_sequence.py @@ -0,0 +1,42 @@ +import unittest + +from bci.search_strategy.bgb_sequence import BiggestGapBisectionSequence +from bci.search_strategy.sequence_strategy import SequenceFinished +from test.sequence.test_sequence_strategy import TestSequenceStrategy as helper + + +class TestBiggestGapBisectionSequence(unittest.TestCase): + + def test_sbg_sequence_always_available(self): + state_factory = helper.create_state_factory(helper.always_has_binary) + sequence = BiggestGapBisectionSequence(state_factory, 12) + index_sequence = [sequence.next().index for _ in range(12)] + assert index_sequence == [0, 99, 49, 74, 24, 36, 61, 86, 12, 42, 67, 92] + self.assertRaises(SequenceFinished, sequence.next) + + def test_sbg_sequence_even_available(self): + state_factory = helper.create_state_factory(helper.only_has_binaries_for_even) + sequence = BiggestGapBisectionSequence(state_factory, 12) + index_sequence = [sequence.next().index for _ in range(12)] + assert index_sequence == [0, 98, 48, 72, 24, 84, 12, 36, 60, 90, 6, 18] + + def test_sbg_sequence_almost_none_available(self): + state_factory = helper.create_state_factory(helper.has_very_few_binaries) + sequence = BiggestGapBisectionSequence(state_factory, 10) + index_sequence = [sequence.next().index for _ in range(10)] + assert index_sequence == [0, 99, 44, 66, 22, 77, 11, 33, 55, 88] + self.assertRaises(SequenceFinished, sequence.next) + + def test_sbg_sequence_sparse_first_half_avaiable(self): + state_factory = helper.create_state_factory(helper.has_very_few_binaries_in_first_half) + sequence = BiggestGapBisectionSequence(state_factory, 17) + index_sequence = [sequence.next().index for _ in range(17)] + assert index_sequence == [0, 99, 50, 22, 74, 44, 86, 62, 92, 56, 68, 80, 95, 53, 59, 65, 71] + + def test_sbg_sequence_always_available_with_evaluated_states(self): + state_factory = helper.create_state_factory(helper.always_has_binary, evaluated_indexes=[49, 61]) + sequence = BiggestGapBisectionSequence(state_factory, 17) + index_sequence = [sequence.next().index for _ in range(15)] + print(index_sequence) + assert index_sequence == [0, 99, 24, 80, 36, 12, 70, 89, 42, 6, 18, 30, 55, 75, 94] + self.assertRaises(SequenceFinished, sequence.next) diff --git a/test/sequence/test_composite_search.py b/test/sequence/test_composite_search.py index 407d819..5a7d247 100644 --- a/test/sequence/test_composite_search.py +++ b/test/sequence/test_composite_search.py @@ -1,72 +1,71 @@ import unittest -from unittest.mock import patch + from bci.search_strategy.composite_search import CompositeSearch -from bci.search_strategy.n_ary_sequence import NArySequence -from bci.search_strategy.n_ary_search import NArySearch from bci.search_strategy.sequence_strategy import SequenceFinished +from test.sequence.test_sequence_strategy import TestSequenceStrategy as helper class TestCompositeSearch(unittest.TestCase): - @staticmethod - def always_true(_): - return True + def test_binary_sequence_always_available_composite(self): + state_factory = helper.create_state_factory( + helper.always_has_binary, + outcome_func=lambda x: True if x < 50 else False) + sequence = CompositeSearch(state_factory, 10) + + # Sequence + index_sequence = [sequence.next().index for _ in range(10)] + assert index_sequence == [0, 99, 49, 74, 24, 36, 61, 86, 12, 42] + + # Simulate that the previous part of the evaluation has been completed + state_factory = helper.create_state_factory( + helper.always_has_binary, + outcome_func=lambda x: True if x < 50 else False, + evaluated_indexes=[0, 99, 49, 74, 24, 36, 61, 86, 12, 42] + ) + sequence.search_strategy._state_factory = state_factory + + # Sequence + index_sequence = [sequence.next().index for _ in range(3)] + assert index_sequence == [55, 52, 50] + + self.assertRaises(SequenceFinished, sequence.next) - @staticmethod - def only_even(x): - return x % 2 == 0 + def test_binary_sequence_always_available_composite_two_shifts(self): + state_factory = helper.create_state_factory( + helper.always_has_binary, + outcome_func=lambda x: True if x < 33 or 81 < x else False) + sequence = CompositeSearch(state_factory, 10) - def test_find_all_shift_index_pairs(self): - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', self.always_true): - def outcome(x) -> bool: - return x < 22 or x > 60 - values = list(range(100)) - seq = CompositeSearch(values, 2, 10, NArySequence, NArySearch) - seq.is_available = self.always_true - expected_elem_sequence = [0, 99, 50, 26, 75, 14, 39, 63, 88, 8] - elem_sequence = [] - for _ in range(10): - elem = seq.next() - seq.update_outcome(elem, outcome(elem)) - elem_sequence.append(elem) - assert expected_elem_sequence == elem_sequence - shift_index_pairs = seq.find_all_shift_index_pairs() - assert shift_index_pairs == [(14, 26), (50, 63)] + # Sequence + index_sequence = [sequence.next().index for _ in range(10)] + assert index_sequence == [0, 99, 49, 74, 24, 36, 61, 86, 12, 42] - expected_elem_search = [21, 24, 23, 22, 57, 61, 60] - elem_search = [] - for _ in range(len(expected_elem_search)): - elem = seq.next() - seq.update_outcome(elem, outcome(elem)) - elem_search.append(elem) - assert seq.sequence_strategy_finished - assert expected_elem_search == elem_search - self.assertRaises(SequenceFinished, seq.next) + # Simulate that the previous part of the evaluation has been completed + state_factory = helper.create_state_factory( + helper.always_has_binary, + outcome_func=lambda x: True if x < 33 or 81 < x else False, + evaluated_indexes=[0, 99, 49, 74, 24, 36, 61, 86, 12, 42] + ) + sequence.search_strategy._state_factory = state_factory - def test_composite_search(self): - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', self.always_true): - def outcome(x) -> bool: - return x < 22 or x > 60 + while True: + try: + print(sequence.next()) + except SequenceFinished: + break - values = list(range(100)) - seq = CompositeSearch(values, 2, 10, NArySequence, NArySearch) - seq.is_available = self.always_true - expected_sequence_part = [0, 99, 50, 26, 75, 14, 39, 63, 88, 8] - expected_search_part = [21, 24, 23, 22, 57, 61, 60] + evaluated_indexes = [state.index for state in sequence.search_strategy._completed_states] - actual_sequence_part = [] - for _ in range(10): - elem = seq.next() - seq.update_outcome(elem, outcome(elem)) - actual_sequence_part.append(elem) - assert expected_sequence_part == actual_sequence_part + assert sequence.sequence_strategy_finished + assert 32 in evaluated_indexes + assert 33 in evaluated_indexes + assert 81 in evaluated_indexes + assert 82 in evaluated_indexes - actual_search_part = [] - while True: - try: - elem = seq.next() - seq.update_outcome(elem, outcome(elem)) - actual_search_part.append(elem) - except SequenceFinished: - break - assert expected_search_part == actual_search_part + assert 1 not in evaluated_indexes + assert 13 not in evaluated_indexes + assert 37 not in evaluated_indexes + assert 50 not in evaluated_indexes + assert 62 not in evaluated_indexes + assert 87 not in evaluated_indexes diff --git a/test/sequence/test_search_strategy.py b/test/sequence/test_search_strategy.py deleted file mode 100644 index abc586a..0000000 --- a/test/sequence/test_search_strategy.py +++ /dev/null @@ -1,170 +0,0 @@ -import unittest -from unittest.mock import patch -from bci.search_strategy.n_ary_search import NArySearch -from bci.search_strategy.sequence_strategy import SequenceFinished - - -class TestSearchStrategy(unittest.TestCase): - @staticmethod - def always_true(_): - return True - - @staticmethod - def only_even(x): - return x.value % 2 == 0 - - @staticmethod - def one_in_15(x): - return x.value % 15 == 0 - - def test_binary_search(self): - def outcome(x) -> bool: - return x < 22 - - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', self.always_true): - values = list(range(100)) - seq = NArySearch(values, 2) - expected_elem_sequence = [0, 99, 50, 26, 14, 21, 24, 23, 22] - elem_sequence = [] - for _ in expected_elem_sequence: - elem = seq.next() - seq.update_outcome(elem, outcome(elem)) - elem_sequence.append(elem) - assert expected_elem_sequence == elem_sequence - self.assertRaises(SequenceFinished, seq.next) - - def test_binary_search_only_even(self): - def outcome(x) -> bool: - return x < 22 - - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', self.only_even): - values = list(range(100)) - seq = NArySearch(values, 2) - expected_elem_sequence = [0, 98, 50, 26, 14, 20, 24, 22] - elem_sequence = [] - for _ in expected_elem_sequence: - elem = seq.next() - seq.update_outcome(elem, outcome(elem)) - elem_sequence.append(elem) - assert expected_elem_sequence == elem_sequence - self.assertRaises(SequenceFinished, seq.next) - - def test_3ary_search_only_even(self): - def outcome(x) -> bool: - return x < 15 - - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', self.only_even): - values = list(range(100)) - seq = NArySearch(values, 3) - expected_elem_sequence = [0, 98, 34, 12, 24, 16, 14] - elem_sequence = [] - for _ in expected_elem_sequence: - elem = seq.next() - seq.update_outcome(elem, outcome(elem)) - elem_sequence.append(elem) - assert expected_elem_sequence == elem_sequence - self.assertRaises(SequenceFinished, seq.next) - - def test_observer_edge_case1(self): - def is_available(x): - return x.value in [766907, 766912, 766922] - - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', is_available): - values = list(range(766907, 766923)) - seq = NArySearch(values, 16) - assert seq.next() == 766907 - seq.update_outcome(766907, False) - assert seq.next() == 766922 - seq.update_outcome(766922, True) - assert seq.next() == 766912 - self.assertRaises(SequenceFinished, seq.next) - - def test_observer_edge_case2(self): - def outcome(x) -> bool: - return x < 454750 - - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', self.one_in_15): - values = list(range(454462, 455227)) - seq = NArySearch(values, 8) - elem_sequence = [] - while True: - try: - elem = seq.next() - seq.update_outcome(elem, outcome(elem)) - elem_sequence.append(elem) - except SequenceFinished: - break - assert 454740 in elem_sequence[-2:] - assert 454725 in elem_sequence[-2:] - - def test_correct_ending_2(self): - def outcome(x) -> bool: - return x < 561011 - - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', self.one_in_15): - values = list(range(560417, 562154)) - seq = NArySearch(values, 2) - elem_sequence = [] - while True: - try: - elem = seq.next() - seq.update_outcome(elem, outcome(elem)) - elem_sequence.append(elem) - except SequenceFinished: - break - assert 561015 in elem_sequence[-2:] - assert 561000 in elem_sequence[-2:] - - def test_correct_ending_4(self): - def outcome(x) -> bool: - return x < 561011 - - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', self.one_in_15): - values = list(range(560417, 562154)) - seq = NArySearch(values, 4) - elem_sequence = [] - while True: - try: - elem = seq.next() - seq.update_outcome(elem, outcome(elem)) - elem_sequence.append(elem) - except SequenceFinished: - break - assert 561015 in elem_sequence[-2:] - assert 561000 in elem_sequence[-2:] - - def test_correct_ending_8(self): - def outcome(x) -> bool: - return x < 561011 - - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', self.one_in_15): - values = list(range(560417, 562154)) - seq = NArySearch(values, 8) - elem_sequence = [] - while True: - try: - elem = seq.next() - seq.update_outcome(elem, outcome(elem)) - elem_sequence.append(elem) - except SequenceFinished: - break - assert 561015 in elem_sequence[-2:] - assert 561000 in elem_sequence[-2:] - - def test_correct_ending_16(self): - def outcome(x) -> bool: - return x < 561011 - - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', self.one_in_15): - values = list(range(560417, 562154)) - seq = NArySearch(values, 16) - elem_sequence = [] - while True: - try: - elem = seq.next() - seq.update_outcome(elem, outcome(elem)) - elem_sequence.append(elem) - except SequenceFinished: - break - assert 561015 in elem_sequence[-2:] - assert 561000 in elem_sequence[-2:] diff --git a/test/sequence/test_sequence_strategy.py b/test/sequence/test_sequence_strategy.py index 4418114..3b80e57 100644 --- a/test/sequence/test_sequence_strategy.py +++ b/test/sequence/test_sequence_strategy.py @@ -1,139 +1,105 @@ import unittest -from unittest.mock import patch -from bci.search_strategy.n_ary_sequence import NArySequence, SequenceFinished +from typing import Callable +from unittest.mock import MagicMock + +from bci.evaluations.logic import EvaluationConfiguration, EvaluationRange +from bci.evaluations.outcome_checker import OutcomeChecker +from bci.search_strategy.sequence_strategy import SequenceStrategy +from bci.version_control.factory import StateFactory +from bci.version_control.states.state import State class TestSequenceStrategy(unittest.TestCase): + ''' + Helper functions to create states and state factories for testing. + ''' + + @staticmethod + def get_states(indexes: list[int], is_available, outcome_func) -> list[State]: + return [TestSequenceStrategy.create_state(index, is_available, outcome_func) for index in indexes] + @staticmethod - def always_true(_): + def create_state_factory( + is_available: Callable, + evaluated_indexes: list[int] = None, + outcome_func: Callable = None) -> StateFactory: + eval_params = MagicMock(spec=EvaluationConfiguration) + eval_params.evaluation_range = MagicMock(spec=EvaluationRange) + eval_params.evaluation_range.major_version_range = [0, 99] + + factory = MagicMock(spec=StateFactory) + factory.__eval_params = eval_params + factory.__outcome_checker = TestSequenceStrategy.create_outcome_checker(outcome_func) + factory.create_state = lambda index: TestSequenceStrategy.create_state(index, is_available, outcome_func) + first_state = TestSequenceStrategy.create_state(0, is_available, outcome_func) + last_state = TestSequenceStrategy.create_state(99, is_available, outcome_func) + factory.boundary_states = (first_state, last_state) + + if evaluated_indexes: + factory.create_evaluated_states = lambda: TestSequenceStrategy.get_states(evaluated_indexes, lambda _: True, outcome_func) + else: + factory.create_evaluated_states = lambda: [] + return factory + + @staticmethod + def create_state(index, is_available: Callable, outcome_func: Callable) -> State: + state = MagicMock(spec=State) + state.index = index + state.has_available_binary = lambda: is_available(index) + state.outcome = outcome_func(index) if outcome_func else None + state.__eq__ = State.__eq__ + state.__repr__ = State.__repr__ + state.get_previous_and_next_state_with_binary = lambda: State.get_previous_and_next_state_with_binary(state) + return state + + @staticmethod + def create_outcome_checker(outcome_func: Callable) -> OutcomeChecker: + if outcome_func: + outcome_checker = MagicMock() + outcome_checker.get_outcome = outcome_func + return outcome_checker + else: + return None + + @staticmethod + def always_has_binary(index) -> bool: return True @staticmethod - def only_even(x): - return x.value % 2 == 0 - - def test_binary_sequence(self): - values = list(range(100)) - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', self.always_true): - seq = NArySequence(values, 2) - assert seq.next() == 0 - assert seq.next() == 99 - assert seq.next() == 50 - - def test_binary_sequence_ending(self): - values = list(range(10)) - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', self.always_true): - seq = NArySequence(values, 2) - assert seq.next() == 0 - assert seq.next() == 9 - assert seq.next() == 5 - assert seq.next() == 3 - assert seq.next() == 8 - assert seq.next() == 2 - assert seq.next() == 4 - assert seq.next() == 7 - assert seq.next() == 1 - assert seq.next() == 6 - self.assertRaises(SequenceFinished, seq.next) - - def test_binary_sequence_ending_only_even_available(self): - values = list(range(100)) - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', self.only_even): - seq = NArySequence(values, 2) - outputted_values = set() - for _ in range(50): - n = seq.next() - assert n % 2 == 0 - assert n not in outputted_values - outputted_values.add(n) - self.assertRaises(SequenceFinished, seq.next) - - def test_3ary_sequence(self): - values = list(range(100)) - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', self.always_true): - seq = NArySequence(values, 3) - assert seq.next() == 0 - assert seq.next() == 99 - assert seq.next() == 34 - assert seq.next() == 67 - assert seq.next() == 12 - assert seq.next() == 23 - assert seq.next() == 46 - assert seq.next() == 57 - assert seq.next() == 79 - assert seq.next() == 90 - - def test_3nary_sequence_ending(self): - values = list(range(10)) - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', self.always_true): - seq = NArySequence(values, 3) - assert seq.next() == 0 - assert seq.next() == 9 - assert seq.next() == 4 - assert seq.next() == 7 - assert seq.next() == 2 - assert seq.next() == 3 - assert seq.next() == 5 - assert seq.next() == 6 - assert seq.next() == 8 - assert seq.next() == 1 - self.assertRaises(SequenceFinished, seq.next) - - def test_3nary_sequence_ending_only_even_available(self): - values = list(range(100)) - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', self.only_even): - seq = NArySequence(values, 3) - outputted_values = set() - for _ in range(50): - n = seq.next() - assert n % 2 == 0 - assert n not in outputted_values - outputted_values.add(n) - self.assertRaises(SequenceFinished, seq.next) - - def test_4ary_sequence(self): - values = list(range(100)) - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', self.always_true): - seq = NArySequence(values, 4) - assert seq.next() == 0 - assert seq.next() == 99 - assert seq.next() == 26 - assert seq.next() == 51 - assert seq.next() == 76 - - def test_4nary_sequence_ending(self): - values = list(range(10)) - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', self.always_true): - seq = NArySequence(values, 4) - assert seq.next() == 0 - assert seq.next() == 9 - assert seq.next() == 3 - assert seq.next() == 5 - assert seq.next() == 7 - assert seq.next() == 1 - assert seq.next() == 2 - assert seq.next() == 4 - assert seq.next() == 6 - assert seq.next() == 8 - self.assertRaises(SequenceFinished, seq.next) - - def test_4nary_sequence_ending_only_even_available(self): - values = list(range(100)) - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', self.only_even): - seq = NArySequence(values, 4) - outputted_values = set() - for _ in range(50): - n = seq.next() - assert n % 2 == 0 - assert n not in outputted_values - outputted_values.add(n) - self.assertRaises(SequenceFinished, seq.next) - - def test_limit(self): - values = list(range(100)) - with patch('bci.search_strategy.sequence_elem.SequenceElem.is_available', self.always_true): - seq = NArySequence(values, 2, limit=37) - for _ in range(37): - seq.next() - self.assertRaises(SequenceFinished, seq.next) + def only_has_binaries_for_even(index) -> bool: + return index % 2 == 0 + + @staticmethod + def has_very_few_binaries(index) -> bool: + return index % 11 == 0 + + @staticmethod + def has_very_few_binaries_in_first_half(index) -> bool: + if index < 50: + return index % 22 == 0 + return True + + ''' + Actual tests + ''' + + def test_find_closest_state_with_available_binary_1(self): + state_factory = TestSequenceStrategy.create_state_factory(TestSequenceStrategy.always_has_binary) + sequence_strategy = SequenceStrategy(state_factory, 0) + state = sequence_strategy._find_closest_state_with_available_binary(state_factory.create_state(5), (state_factory.create_state(0), state_factory.create_state(10))) + assert state is not None + assert state.index == 5 + + def test_find_closest_state_with_available_binary_2(self): + state_factory = TestSequenceStrategy.create_state_factory(TestSequenceStrategy.only_has_binaries_for_even) + sequence_strategy = SequenceStrategy(state_factory, 0) + state = sequence_strategy._find_closest_state_with_available_binary(state_factory.create_state(5), (state_factory.create_state(0), state_factory.create_state(10))) + assert state is not None + assert state.index == 4 + + def test_find_closest_state_with_available_binary_3(self): + state_factory = TestSequenceStrategy.create_state_factory(TestSequenceStrategy.only_has_binaries_for_even) + sequence_strategy = SequenceStrategy(state_factory, 0) + state = sequence_strategy._find_closest_state_with_available_binary(state_factory.create_state(1), (state_factory.create_state(0), state_factory.create_state(2))) + assert state is None