diff --git a/.dockerignore b/.dockerignore index ae2875b..fd5fbff 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,6 @@ **/__pycache__ +**/dist +**/node_modules .env .flake8 .git diff --git a/.gitignore b/.gitignore index 9058405..2098f40 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ browser/binaries/ database/data/ !**/.gitkeep **/node_modules +**/junit.xml # Created by https://www.toptal.com/developers/gitignore/api/intellij,python,flask,macos # Edit at https://www.toptal.com/developers/gitignore?templates=intellij,python,flask,macos diff --git a/Dockerfile b/Dockerfile index a872109..9859532 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,8 +10,8 @@ FROM python:3.11-slim-buster AS base WORKDIR /app RUN apt-get update -y -RUN apt install -y curl gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils libgbm-dev xvfb dbus-x11 libnss3-tools python3-pip vim multiarch-support wget git procps \ - && rm -rf /var/lib/apt/lists/* +RUN apt install -y curl gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils libgbm-dev xvfb dbus-x11 libnss3-tools python3-pip vim multiarch-support wget git procps &&\ + rm -rf /var/lib/apt/lists/* RUN curl -sSL https://get.docker.com/ | sh diff --git a/README.md b/README.md index 69dd00a..6b3e097 100644 --- a/README.md +++ b/README.md @@ -63,14 +63,14 @@ Follow these steps to get started: Launch BugHog using the following command: ```bash -docker compose up +docker compose up -d ``` > :warning: If you use `sudo` with this command, the `PWD` environment variable won't be passed to the BugHog containers, which is necessary for dynamically starting worker containers. > To avoid this, explicitly pass on this variable: `sudo PWD=$PWD docker compose up`. Open your web browser and navigate to [http://localhost:5000](http://localhost:5000) to access the graphical interface. -BugHog is started on a remote server, substitute 'localhost' with its IP address. +If BugHog is started on a remote server, substitute 'localhost' with the appropriate IP address. BugHog can be stopped through: ```bash @@ -98,7 +98,7 @@ Be sure to restart the BugHog framework when you add a new experiment: ```bash docker compose down -docker compose up +docker compose up -d ``` ## Development @@ -114,13 +114,26 @@ For debugging the core application, consider using the VS Code dev container. You can utilize the configuration in [.devcontainer](.devcontainer) for this. -## Additional help +## Contact -Don't hesitate to open a [GitHub issue](https://github.com/DistriNet/BugHog/issues/new) if you come across a bug, want to suggest a feature, or have any questions! +Don't hesitate to open a [GitHub issue](https://github.com/DistriNet/BugHog/issues/new) if you encounter a bug or want to suggest a feature! + +For questions or collaboration, you can reach out to [Gertjan Franken](https://distrinet.cs.kuleuven.be/people/GertjanFranken). ## Troubleshooting +If something isn't working as expected, check out the troubleshooting tips below. +If you don't find a solution, don't hesitate to open a [GitHub issue](https://github.com/DistriNet/BugHog/issues/new). +Feel free to include any relevant logs. + + +### Consult the logs + +- Try launching BugHog without the `-d` flag to see logging output in the terminal, which might provide more information about the issue. +- For more detailed logs at the `DEBUG` level, check out the [logs](/logs) folder for all logging files. + + ### WSL on Windows - Ensure you clone the BugHog project to the WSL file system instead of the Windows file system, and launch it from there. diff --git a/analysis/plot_factory.py b/analysis/plot_factory.py index dfb4e85..57df9ed 100644 --- a/analysis/plot_factory.py +++ b/analysis/plot_factory.py @@ -1,8 +1,9 @@ +from bokeh.colors.named import green, black from bokeh.core.validation import silence from bokeh.core.validation.warnings import PALETTE_LENGTH_FACTORS_MISMATCH from bokeh.embed import file_html from bokeh.models import BasicTickFormatter, ColumnDataSource, HoverTool -from bokeh.models.glyphs import Circle +from bokeh.models.glyphs import Circle, Rect, Text from bokeh.palettes import Iridescent23 from bokeh.plotting import figure, output_file, show from bokeh.resources import CDN @@ -11,9 +12,9 @@ from bci.database.mongo.mongodb import MongoDB from bci.evaluations.logic import PlotParameters - silence(PALETTE_LENGTH_FACTORS_MISMATCH, True) + class PlotFactory: @staticmethod @@ -54,28 +55,49 @@ def create_html_plot_string(params: PlotParameters, db: MongoDB) -> tuple[str, i @staticmethod def __create_plot(params: PlotParameters, db: MongoDB): - docs = db.get_documents_for_plotting(params) - if len(docs) == 0: - return None, 0 - - data = PlotFactory.__add_outcome_info(params, docs) + # Fetch results docs for revisions + revision_docs = db.get_documents_for_plotting(params) + revision_results = PlotFactory.__add_outcome_info(params, revision_docs) # Create a data source with task start and end times, task names, and task colors - source = ColumnDataSource(data=data) + revision_source = ColumnDataSource(data=revision_results) + if revision_results: + # define a color map based on the 'version' column + revision_color_map = factor_cmap('browser_version_str', Iridescent23, list(set(revision_source.data['browser_version_str']))) + + # Fetch results focs for versions + version_docs = db.get_documents_for_plotting(params, releases=True) + version_results = PlotFactory.__add_outcome_info(params, version_docs) + version_source = ColumnDataSource(data=version_results) + + total_number_of_docs = len(revision_docs) + len(version_docs) + if total_number_of_docs == 0: + return None, 0 - # define a color map based on the 'version' column - color_map = factor_cmap('browser_version_str', Iridescent23, list(set(source.data['browser_version_str']))) + if revision_docs and version_docs: + x_min = min(revision_source.data['revision_number'] + version_source.data['revision_number']) + x_max = max(revision_source.data['revision_number'] + version_source.data['revision_number']) + elif revision_docs: + x_min = min(revision_source.data['revision_number']) + x_max = max(revision_source.data['revision_number']) + else: + x_min = min(version_source.data['revision_number']) + x_max = max(version_source.data['revision_number']) # Create a figure and add the task circles plot = figure( title='Gantt Chart with Points', - x_range=(min(source.data['revision_number']), max(source.data['revision_number'])), + x_range=(x_min, x_max), y_range=['Error', 'Not reproduced', 'Reproduced'], - height=350, - width=700, + height=470, + width=900, tools='xwheel_zoom,reset,pan', active_scroll='xwheel_zoom') - plot.add_glyph(source, Circle(x='revision_number', y='outcome', fill_color=color_map, fill_alpha=0.8, size=15)) + if revision_results: + plot.add_glyph(revision_source, Circle(x='revision_number', y='outcome', fill_color=revision_color_map, fill_alpha=0.8, radius=6, radius_units='screen')) + if version_results: + plot.add_glyph(version_source, Rect(x='revision_number', y='outcome', fill_color=green, width=12, height=12, angle=45, fill_alpha=0.8, width_units='screen', height_units='screen', angle_units='deg')) + plot.add_glyph(version_source, Text(x='revision_number', y='outcome', x_offset=0, y_offset=-20, text='browser_version_str', text_color=black, text_align='center', text_font_size='14px', angle=45, angle_units='deg')) # Formatting plot.xaxis[0].formatter = BasicTickFormatter(use_scientific=False) @@ -87,7 +109,7 @@ def __create_plot(params: PlotParameters, db: MongoDB): ) plot.add_tools(hover) - return plot, len(docs) + return plot, total_number_of_docs @staticmethod def __transform_to_bokeh_compatible(docs: list) -> dict: @@ -105,17 +127,24 @@ def __add_outcome_info(params: PlotParameters, docs: dict): target_mech_id = params.target_mech_id if params.target_mech_id else params.mech_group for doc in docs: + # Backwards compatibility requests_to_target = list(filter(lambda x: f'/report/?leak={target_mech_id}' in x['url'], doc['results']['requests'])) - requests_to_baseline = list(filter(lambda x: '/report/?leak=baseline' in x['url'], doc['results']['requests'])) + # New way + if [req_var for req_var in doc['results']['req_vars'] if req_var['var'] == 'reproduced' and req_var['val'] == 'OK'] or \ + [log_var for log_var in doc['results']['log_vars'] if log_var['var'] == 'reproduced' and log_var['val'] == 'OK']: + reproduced = True + else: + reproduced = False + new_doc = { - 'revision_number': doc['revision_number'], + 'revision_number': doc['state']['revision_number'], 'browser_version': int(doc['browser_version'].split('.')[0]), 'browser_version_str': doc['browser_version'].split('.')[0] } - if doc['dirty'] or len(requests_to_baseline) == 0: + if doc['dirty']: new_doc['outcome'] = 'Error' docs_with_outcome.append(new_doc) - elif len(requests_to_target) > 0: + elif len(requests_to_target) > 0 or reproduced: new_doc['outcome'] = 'Reproduced' docs_with_outcome.append(new_doc) else: diff --git a/bci/browser/automation/terminal.py b/bci/browser/automation/terminal.py index d4b57b6..d1fe6f7 100644 --- a/bci/browser/automation/terminal.py +++ b/bci/browser/automation/terminal.py @@ -3,8 +3,7 @@ import subprocess import time - -logger = logging.getLogger('bci') +logger = logging.getLogger(__name__) class TerminalAutomation: @@ -14,11 +13,12 @@ def run(url: str, args: list[str], seconds_per_visit: int): logger.debug("Starting browser process...") args.append(url) logger.debug(f'Command string: \'{" ".join(args)}\'') - proc = subprocess.Popen( - args, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) + with open('/tmp/browser.log', 'a') as file: + proc = subprocess.Popen( + args, + stdout=file, + stderr=file + ) time.sleep(seconds_per_visit) diff --git a/bci/browser/binary/binary.py b/bci/browser/binary/binary.py index 2ca1fab..7b84362 100644 --- a/bci/browser/binary/binary.py +++ b/bci/browser/binary/binary.py @@ -8,7 +8,7 @@ from bci.browser.binary.artisanal_manager import ArtisanalBuildManager from bci.version_control.states.state import State -logger = logging.getLogger('bci') +logger = logging.getLogger(__name__) class Binary: @@ -16,10 +16,6 @@ class Binary: def __init__(self, state: State): self.state = state self.__version = None - self.only_releases = None - - def set_only_releases(self, only_releases): - self.only_releases = only_releases @property def version(self) -> str: @@ -73,24 +69,26 @@ def fetch_binary(self): if self.is_built(): return # Try to download binary - elif self.__class__.has_available_binary_online(self.state): + elif self.is_available_online(): self.download_binary() else: - raise BuildNotAvailableError(self.browser_name, self.state.revision_number) + raise BuildNotAvailableError(self.browser_name, self.state) - @abstractmethod - def download_binary(self): - pass + 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_or_online(self): - return self.has_available_binary_locally() or self.has_available_binary_online() - - def has_available_binary_locally(self): + def is_available_locally(self): bin_path = self.get_bin_path() return bin_path is not None + def is_available_online(self): + return self.state.has_online_binary() + @abstractmethod - def has_available_binary_online(self): + def download_binary(self): pass def is_built(self): @@ -114,8 +112,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", str(self.state.revision_number), self.executable_name) - return os.path.join(self.bin_folder_path, "downloaded", str(self.state.revision_number), 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() @@ -128,14 +126,14 @@ 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", str(self.state.revision_number)) - return os.path.join(self.bin_folder_path, "downloaded", str(self.state.revision_number)) + 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 not util.rmtree(path): - self.logger.error("Could not remove folder '%s'" % path) + logger.error("Could not remove folder '%s'" % path) @abstractmethod def get_driver_version(self, browser_version): diff --git a/bci/browser/binary/factory.py b/bci/browser/binary/factory.py index 1283e74..37683d6 100644 --- a/bci/browser/binary/factory.py +++ b/bci/browser/binary/factory.py @@ -25,7 +25,7 @@ def binary_is_available(state: State) -> bool: def __has_available_binary_online(state: State) -> bool: - return __get_class(state.browser_name).has_available_binary_online(state) + return __get_class(state.browser_name).has_available_binary_online() def __has_available_binary_artisanal(state: State) -> bool: @@ -33,7 +33,7 @@ def __has_available_binary_artisanal(state: State) -> bool: def get_binary(state: State) -> Binary: - return __get_object(state.browser_name, state) + return __get_object(state) def __get_class(browser_name: str) -> Binary.__class__: @@ -46,8 +46,8 @@ def __get_class(browser_name: str) -> Binary.__class__: raise ValueError(f'Unknown browser {browser_name}') -def __get_object(browser_name: str, state: State) -> Binary: - match browser_name: +def __get_object(state: State) -> Binary: + match state.browser_name: case 'chromium': return ChromiumBinary(state) case 'firefox': diff --git a/bci/browser/binary/vendors/chromium.py b/bci/browser/binary/vendors/chromium.py index cc22362..75ab2c2 100644 --- a/bci/browser/binary/vendors/chromium.py +++ b/bci/browser/binary/vendors/chromium.py @@ -9,7 +9,6 @@ from bci import cli, util from bci.browser.binary.artisanal_manager import ArtisanalBuildManager from bci.browser.binary.binary import Binary -from bci.database.mongo.mongodb import MongoDB from bci.version_control.states.state import State logger = logging.getLogger('bci') @@ -51,32 +50,17 @@ def bin_folder_path(self) -> str: # Downloadable binaries - @staticmethod - def has_available_binary_online(state: State) -> bool: - cached_binary_available_online = MongoDB.has_binary_available_online('chromium', state) - 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{state.revision_number}%2Fchrome-linux.zip' - req = requests.get(url) - has_binary_online = req.status_code == 200 - MongoDB.store_binary_availability_online_cache('chromium', state, has_binary_online) - return has_binary_online - def download_binary(self): - rev_number = self.state.revision_number - - if self.has_available_binary_locally(): - logger.debug(f'{self.rev_number} was already downloaded ({self.get_bin_path()})') + if self.is_available_locally(): + logger.debug(f'Binary for {self.state} was already downloaded ({self.get_bin_path()})') return - url = \ - "https://www.googleapis.com/download/storage/v1/b/chromium-browser-snapshots/o/%s%%2F%s%%2Fchrome-%s.zip?alt=media"\ - % ('Linux_x64', rev_number, 'linux') - logger.info(f'Downloading {rev_number} from \'{url}\'') - zip_file_path = f'/tmp/{rev_number}/archive.zip' + binary_url = self.state.get_online_binary_url() + logger.info(f'Downloading binary for {self.state} from \'{binary_url}\'') + zip_file_path = f'/tmp/{self.state.name}/archive.zip' if os.path.exists(os.path.dirname(zip_file_path)): shutil.rmtree(os.path.dirname(zip_file_path)) os.makedirs(os.path.dirname(zip_file_path)) - with requests.get(url, stream=True) as req: + with requests.get(binary_url, stream=True) as req: with open(zip_file_path, 'wb') as file: shutil.copyfileobj(req.raw, file) with zipfile.ZipFile(zip_file_path, 'r') as zip_ref: diff --git a/bci/browser/binary/vendors/firefox.py b/bci/browser/binary/vendors/firefox.py index e5c34a3..1c1fb4e 100644 --- a/bci/browser/binary/vendors/firefox.py +++ b/bci/browser/binary/vendors/firefox.py @@ -9,7 +9,7 @@ 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.firefox import (BINARY_AVAILABILITY_MAPPING, +from bci.version_control.states.revisions.firefox import (BINARY_AVAILABILITY_MAPPING, REVISION_NUMBER_MAPPING) from bci.version_control.states.state import State @@ -40,26 +40,13 @@ def browser_name(self) -> str: def bin_folder_path(self) -> str: return BIN_FOLDER_PATH - @staticmethod - def has_available_binary_online(state: State) -> bool: - if state._revision_id: - return state._revision_id in BINARY_AVAILABILITY_MAPPING - if state._revision_number: - return str(state._revision_number) in REVISION_NUMBER_MAPPING - def download_binary(self): - rev_id = self.state.revision_id - rev_number = self.state.revision_number - - if self.only_releases: - binary_url = f'https://ftp.mozilla.org/pub/firefox/releases/{self.version}.0/linux-x86_64/en-US/firefox-{self.version}.0.tar.bz2' - else: - # binary_url = MongoDB.get_binary_url("firefox", changeset_id) - binary_base_url = BINARY_AVAILABILITY_MAPPING[rev_id]["files_url"] - app_version = BINARY_AVAILABILITY_MAPPING[rev_id]["app_version"] - binary_url = f"{binary_base_url}firefox-{app_version}.en-US.linux-x86_64.tar.bz2" - logger.debug(f'Downloading {rev_number} from \'{binary_url}\'') - tar_file_path = f'/tmp/{rev_number}/archive.tar.bz2' + if self.is_available_locally(): + logger.debug(f'Binary for {self.state} was already downloaded ({self.get_bin_path()})') + return + binary_url = self.state.get_online_binary_url() + logger.debug(f'Downloading binary for {self.state} from \'{binary_url}\'') + tar_file_path = f'/tmp/{self.state.name}/archive.tar.bz2' if os.path.exists(os.path.dirname(tar_file_path)): shutil.rmtree(os.path.dirname(tar_file_path)) os.makedirs(os.path.dirname(tar_file_path)) diff --git a/bci/browser/configuration/browser.py b/bci/browser/configuration/browser.py index 9a20c3b..1178ece 100644 --- a/bci/browser/configuration/browser.py +++ b/bci/browser/configuration/browser.py @@ -74,7 +74,7 @@ def __remove_profile_folder(self): self._profile_path = None def __get_execution_folder_path(self) -> str: - return os.path.join(EXECUTION_PARENT_FOLDER, str(self.state.revision_number)) + return os.path.join(EXECUTION_PARENT_FOLDER, str(self.state.name)) def _get_executable_file_path(self) -> str: return os.path.join(self.__get_execution_folder_path(), self.binary.executable_name) diff --git a/bci/browser/configuration/chromium.py b/bci/browser/configuration/chromium.py index 4bc95ed..6108328 100644 --- a/bci/browser/configuration/chromium.py +++ b/bci/browser/configuration/chromium.py @@ -11,7 +11,6 @@ SELENIUM_USED_FLAGS = [ '--use-fake-ui-for-media-stream', '--ignore-certificate-errors', - '--use-fake-ui-for-media-stream', '--disable-background-networking', '--disable-client-side-phishing-detection', '--disable-component-update', @@ -22,15 +21,12 @@ '--disable-prompt-on-repost', '--disable-sync', '--disable-web-resources', - '--enable-logging', - '--log-level=0', '--metrics-recording-only', '--no-first-run', '--password-store=basic', '--safebrowsing-disable-auto-update', '--use-mock-keychain', '--no-sandbox', - '--ignore-certificate-errors' ] @@ -41,6 +37,14 @@ def _get_terminal_args(self) -> list[str]: args = [self._get_executable_file_path()] args.append(f'--user-data-dir={self._profile_path}') + # Enable logging + args.append('--enable-logging') + args.append('--v=1') + args.append('--log-level=0') + # Headless changed from version +/- 110 onwards: https://developer.chrome.com/docs/chromium/new-headless + # Using the `--headless` flag will crash the browser for these later versions. + # Also see: https://github.com/DistriNet/BugHog/issues/12 + # args.append('--headless=new') # From Chrome if 'btpc' in self.browser_config.browser_setting: # This is handled in the profile folder diff --git a/bci/browser/support.py b/bci/browser/support.py new file mode 100644 index 0000000..a122b26 --- /dev/null +++ b/bci/browser/support.py @@ -0,0 +1,23 @@ +import dataclasses + +import bci.version_control.repository.online.chromium as chromium_repo +import bci.version_control.repository.online.firefox as firefox_repo + +from bci.browser.configuration import chromium, firefox + + +def get_chromium_support() -> dict: + return { + 'name': 'chromium', + 'min_version': 20, + 'max_version': chromium_repo.get_most_recent_major_version(), + 'options': [dataclasses.asdict(option) for option in chromium.SUPPORTED_OPTIONS] + } + +def get_firefox_support() -> dict: + return { + 'name': 'firefox', + 'min_version': 20, + 'max_version': firefox_repo.get_most_recent_major_version(), + 'options': [dataclasses.asdict(option) for option in firefox.SUPPORTED_OPTIONS] + } diff --git a/bci/configuration.py b/bci/configuration.py index 6a45709..c0f6fe1 100644 --- a/bci/configuration.py +++ b/bci/configuration.py @@ -29,12 +29,22 @@ def get_browser_config_class(browser: str): @staticmethod def check_required_env_parameters() -> bool: - 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"') - return False + 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".') + fatal = True else: logger.debug(f'HOST_PWD={host_pwd}') - return True + + # BUGHOG_VERSION + if (bughog_version := os.getenv('BUGHOG_VERSION')) in ['', None]: + logger.fatal('"BUGHOG_VERSION" variable is not set.') + fatal = True + else: + logger.info(f'Starting BugHog with tag "{bughog_version}"') + + return not fatal @staticmethod def initialize_folders(): @@ -67,6 +77,15 @@ def get_database_connection_params() -> DatabaseConnectionParameters: 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] + return bughog_version + class Chromium: diff --git a/bci/database/mongo/mongodb.py b/bci/database/mongo/mongodb.py index b560970..7918776 100644 --- a/bci/database/mongo/mongodb.py +++ b/bci/database/mongo/mongodb.py @@ -4,11 +4,14 @@ from abc import ABC from datetime import datetime, timezone -from pymongo import MongoClient, UpdateOne +from flatten_dict import flatten +from pymongo import MongoClient from pymongo.collection import Collection from pymongo.errors import ServerSelectionTimeoutError -from bci.evaluations.logic import DatabaseConnectionParameters, PlotParameters, TestParameters, TestResult, WorkerParameters +from bci.evaluations.logic import (DatabaseConnectionParameters, + PlotParameters, TestParameters, TestResult, + WorkerParameters) from bci.version_control.states.state import State logger = logging.getLogger(__name__) @@ -95,10 +98,9 @@ def store_result(self, result: TestResult): 'browser_config': browser_config.browser_setting, 'cli_options': browser_config.cli_options, 'extensions': browser_config.extensions, - 'revision_id': result.params.state.revision_id, - 'revision_number': result.params.state.revision_number, + 'state': result.params.state.to_dict(), 'mech_group': result.params.mech_group, - 'results': result.requests, + 'results': result.data, 'dirty': result.is_dirty, 'ts': str(datetime.now(timezone.utc).replace(microsecond=0)) } @@ -106,7 +108,7 @@ def store_result(self, result: TestResult): document["driver_version"] = result.driver_version if browser_config.browser_name == "firefox": - build_id = self.get_build_id_firefox(result.params.state.revision_id) + build_id = self.get_build_id_firefox(result.params.state) if build_id is None: document["artisanal"] = True document["build_id"] = "artisanal" @@ -119,13 +121,15 @@ def get_result(self, params: TestParameters) -> TestResult: collection = self.__get_data_collection(params) query = self.__to_query(params) document = collection.find_one(query) - return TestResult( - params, - document['browser_version'], - document['binary_origin'], - requests=document['results']['requests'] if 'requests' in document['results'] else None, - is_dirty=document['dirty'] - ) + if document: + return params.create_test_result_with( + document['browser_version'], + document['binary_origin'], + document['results'], + document['dirty'] + ) + else: + logger.error(f'Could not find document for query {query}') def has_result(self, params: TestParameters) -> bool: collection = self.__get_data_collection(params) @@ -141,7 +145,7 @@ def has_all_results(self, params: WorkerParameters) -> bool: def __to_query(self, params: TestParameters) -> dict: query = { - 'revision_number': params.state.revision_number, + 'state': params.state.to_dict(), 'browser_automation': params.evaluation_configuration.automation, 'browser_config': params.browser_configuration.browser_setting, 'mech_group': params.mech_group @@ -180,7 +184,7 @@ def get_binary_availability_collection(browser_name: str): @staticmethod def has_binary_available_online(browser: str, state: State): collection = MongoDB.get_binary_availability_collection(browser) - document = collection.find_one({'revision_number': state.revision_number}) + document = collection.find_one({'state': state.to_dict(make_complete=False)}) if document is None: return None return document["binary_online"] @@ -194,7 +198,7 @@ def get_stored_binary_availability(browser): }, { "_id": False, - "state_id": True, + "state": True, } ) if browser == "firefox": @@ -202,32 +206,29 @@ def get_stored_binary_availability(browser): return result @staticmethod - def get_binary_url(browser: str, state_id: str): - collection = MongoDB.get_binary_availability_collection(browser) - result = collection.find_one( - { - "state_id": int(state_id) if state_id.isdigit() else state_id - }, - { - "_id": False, - "url": True - } - ) - if len(result) == 0: - raise AttributeError("No entry found for state_id '%s'" % state_id) - return result["url"] + def get_complete_state_dict_from_binary_availability_cache(state: State): + 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) + } + 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) collection.update_one( { - 'revision_number': state.revision_number + 'state': state.to_dict() }, { "$set": { - 'revision_number': state.revision_number, + 'state': state.to_dict(), 'binary_online': binary_online, 'url': url, 'ts': str(datetime.now(timezone.utc).replace(microsecond=0)) @@ -237,34 +238,11 @@ def store_binary_availability_online_cache(browser: str, state: State, binary_on ) @staticmethod - def store_binary_availability_online_cache_firefox(upsert_data): - collection = MongoDB.get_binary_availability_collection("firefox") - - bulk_update = [] - for attributes in upsert_data: - update = UpdateOne( - { - "state_id": attributes["changeset_id"] - }, - { - "$set": { - "state_id": attributes["changeset_id"], - "binary_online": attributes["binary_online"], - "url": attributes["binary_url"], - 'build_id': attributes["build_id"], - "ts": str(datetime.now(timezone.utc).replace(microsecond=0)) - } - }, upsert=True) - bulk_update.append(update) - if len(bulk_update) > 0: - collection.bulk_write(bulk_update) - - @staticmethod - def get_build_id_firefox(revision_id: str): + def get_build_id_firefox(state: State): collection = MongoDB.get_binary_availability_collection("firefox") result = collection.find_one({ - "state_id": revision_id + "state": state.to_dict() }, { "_id": False, "build_id": 1 @@ -275,11 +253,12 @@ def get_build_id_firefox(revision_id: str): return None return result["build_id"] - def get_documents_for_plotting(self, params: PlotParameters): + 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 @@ -292,7 +271,7 @@ def get_documents_for_plotting(self, params: PlotParameters): if params.cli_options: query['cli_options']['$all'] = params.cli_options if params.revision_number_range: - query['revision_number'] = { + query['state.revision_number'] = { '$gte': params.revision_number_range[0], '$lte': params.revision_number_range[1] } @@ -309,7 +288,7 @@ def get_documents_for_plotting(self, params: PlotParameters): { '$project': { '_id': False, - 'revision_number': True, + 'state': True, 'browser_version': True, 'dirty': True, 'results': True diff --git a/bci/distribution/worker_manager.py b/bci/distribution/worker_manager.py index cc42b17..32c69b8 100644 --- a/bci/distribution/worker_manager.py +++ b/bci/distribution/worker_manager.py @@ -9,6 +9,7 @@ import docker.errors from bci import worker +from bci.configuration import Global from bci.evaluations.logic import WorkerParameters logger = logging.getLogger('bci') @@ -63,7 +64,7 @@ def start_container_thread(): logger.info(f'Removing old container \'{container.attrs["Name"]}\' to start new one') container.remove(force=True) self.client.containers.run( - 'bughog/worker:latest', + f'bughog/worker:{Global.get_tag()}', name=container_name, hostname=container_name, shm_size='2gb', @@ -92,7 +93,7 @@ def start_container_thread(): thread = threading.Thread(target=start_container_thread) thread.start() - logger.info(f'Container \'{container_name}\' started experiments for revision \'{params.state.revision_number}\'') + 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) diff --git a/bci/evaluations/collector.py b/bci/evaluations/collector.py new file mode 100644 index 0000000..c9e3278 --- /dev/null +++ b/bci/evaluations/collector.py @@ -0,0 +1,44 @@ +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 + +logger = logging.getLogger(__name__) + + +class Type(Enum): + REQUESTS = 1 + LOGS = 2 + + +class Collector: + + def __init__(self, types: list[Type]) -> None: + self.collectors: list[BaseCollector] = [] + if Type.REQUESTS in types: + collector = RequestCollector() + self.collectors.append(collector) + if Type.LOGS in types: + collector = LogCollector() + self.collectors.append(collector) + logger.debug(f'Using {len(self.collectors)} result collectors') + + def start(self): + for collector in self.collectors: + collector.start() + + def stop(self): + for collector in self.collectors: + collector.stop() + + @abstractmethod + def collect_results(self) -> dict: + all_data = {} + for collector in self.collectors: + all_data.update(collector.data) + logger.debug(f'Collected data: {all_data}') + return all_data diff --git a/bci/evaluations/collectors/base.py b/bci/evaluations/collectors/base.py new file mode 100644 index 0000000..903ed5d --- /dev/null +++ b/bci/evaluations/collectors/base.py @@ -0,0 +1,41 @@ +import re +from abc import abstractmethod + + +class BaseCollector: + + def __init__(self) -> None: + self.data = {} + + @abstractmethod + def start(): + pass + + @abstractmethod + def stop(): + pass + + @staticmethod + def _parse_bughog_variables(raw_log_lines: list[str], regex) -> list[tuple[str, str]]: + ''' + Parses the given `raw_log_lines` for matches against the given `regex`. + ''' + data = [] + regex_match_lists = [re.findall(regex, line) for line in raw_log_lines if re.search(regex, line)] + # Flatten list + regex_matches = [regex_match for regex_match_list in regex_match_lists for regex_match in regex_match_list] + for match in regex_matches: + var = match[0] + val = match[1] + BaseCollector._add_val_var_pair(var, val, data) + return data + + @staticmethod + def _add_val_var_pair(var: str, val: str, data: list) -> list: + for entry in data: + if entry['var'] == var and entry['val'] == val: + return data + data.append({ + 'var': var, + 'val': val + }) diff --git a/bci/evaluations/collectors/logs.py b/bci/evaluations/collectors/logs.py new file mode 100644 index 0000000..48a2078 --- /dev/null +++ b/bci/evaluations/collectors/logs.py @@ -0,0 +1,21 @@ +from .base import BaseCollector + + +class LogCollector(BaseCollector): + + def __init__(self) -> None: + super().__init__() + self.data['log_vars'] = [] + + def start(self): + with open('/tmp/browser.log', 'w') as file: + file.write('') + + def stop(self): + data = [] + regex = r'\+\+\+bughog_(.+)=(.+)\+\+\+' + with open('/tmp/browser.log', 'r+') as log_file: + log_lines = [line for line in log_file.readlines()] + log_file.write('') + data = self._parse_bughog_variables(log_lines, regex) + self.data['log_vars'] = data diff --git a/bci/http/collector.py b/bci/evaluations/collectors/requests.py similarity index 76% rename from bci/http/collector.py rename to bci/evaluations/collectors/requests.py index 759afc1..d5b9efe 100644 --- a/bci/http/collector.py +++ b/bci/evaluations/collectors/requests.py @@ -1,10 +1,11 @@ - import http.server import json import logging import socketserver from threading import Thread +from .base import BaseCollector + logger = logging.getLogger(__name__) PORT = 5001 @@ -24,7 +25,7 @@ def log_message(self, *_): logger.debug(f'Received request with body: {self.request_body}') request_body = json.loads(self.request_body) - self.collector.requests.append(request_body) + self.collector.data['requests'].append(request_body) def do_POST(self): content_length = int(self.headers['Content-Length']) @@ -35,12 +36,14 @@ def do_POST(self): self.wfile.write(b'Post request received') -class Collector: +class RequestCollector(BaseCollector): def __init__(self): + super().__init__() self.__httpd = None self.__thread = None - self.requests = [] + self.data['requests'] = [] + self.data['req_vars'] = [] def start(self): logger.debug('Starting collector...') @@ -51,8 +54,12 @@ def start(self): self.__thread.start() def stop(self): - logger.debug('Stopping collector...') + data = [] + regex = r'bughog_(.+)=(.+)' if self.__httpd: self.__httpd.shutdown() self.__thread.join() self.__httpd.server_close() + request_urls = [request['url'] for request in self.data['requests'] if 'url' in request] + data = self._parse_bughog_variables(request_urls, regex) + self.data['req_vars'] = data diff --git a/bci/evaluations/custom/custom_evaluation.py b/bci/evaluations/custom/custom_evaluation.py index cc60f7d..1822996 100644 --- a/bci/evaluations/custom/custom_evaluation.py +++ b/bci/evaluations/custom/custom_evaluation.py @@ -1,14 +1,14 @@ import logging import os from unittest import TestResult -from bci.browser.configuration.browser import Browser +from bci.browser.configuration.browser import Browser from bci.configuration import Global +from bci.evaluations.collector import Collector +from bci.evaluations.collector import Type from bci.evaluations.custom.custom_mongodb import CustomMongoDB from bci.evaluations.evaluation_framework import EvaluationFramework from bci.evaluations.logic import TestParameters -from bci.http.collector import Collector - logger = logging.getLogger(__name__) @@ -20,7 +20,6 @@ class CustomEvaluationFramework(EvaluationFramework): def __init__(self): super().__init__() self.tests_per_project = {} - self.tests = {} self.initialize_tests_and_url_queues() def initialize_tests_and_url_queues(self): @@ -36,7 +35,6 @@ def initialize_tests_and_url_queues(self): # If an URL queue is specified, it is parsed and used with open(url_queue_file_path) as file: self.tests_per_project[project_name][test_name] = file.readlines() - self.tests[test_name] = self.tests_per_project[project_name][test_name] else: # Otherwise, a default URL queue is used, based on the domain that hosts the main page test_folder_path = os.path.join(project_path, test_name) @@ -45,21 +43,20 @@ def initialize_tests_and_url_queues(self): if os.path.exists(main_folder_path): self.tests_per_project[project_name][test_name] = [ f'https://{domain}/{project_name}/{test_name}/main', - 'https://a.test/report/?leak=baseline' + 'https://a.test/report/?bughog_sanity_check=OK' ] - self.tests[test_name] = self.tests_per_project[project_name][test_name] def perform_specific_evaluation(self, browser: Browser, params: TestParameters) -> TestResult: logger.info(f'Starting test for {params}') browser_version = browser.version binary_origin = browser.get_binary_origin() - collector = Collector() + collector = Collector([Type.REQUESTS, Type.LOGS]) collector.start() is_dirty = False try: - url_queue = self.tests[params.mech_group] + url_queue = self.tests_per_project[params.evaluation_configuration.project][params.mech_group] for url in url_queue: tries = 0 while tries < 3: @@ -70,13 +67,17 @@ def perform_specific_evaluation(self, browser: Browser, params: TestParameters) is_dirty = True finally: collector.stop() + data = collector.collect_results() if not is_dirty: - if len([request for request in collector.requests if 'report/?leak=baseline' in request['url']]) == 0: + # 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']: + pass + # Old way for backwards compatibility + elif [request for request in data['requests'] if 'report/?leak=baseline' in request['url']]: + pass + else: is_dirty = True - result = { - 'requests': collector.requests - } - return params.create_test_result_with(browser_version, binary_origin, result, is_dirty) + return params.create_test_result_with(browser_version, binary_origin, data, is_dirty) def get_mech_groups(self, project=None): if project: diff --git a/bci/evaluations/evaluation_framework.py b/bci/evaluations/evaluation_framework.py index e31c767..fe09276 100644 --- a/bci/evaluations/evaluation_framework.py +++ b/bci/evaluations/evaluation_framework.py @@ -8,7 +8,7 @@ from bci.database.mongo.mongodb import MongoDB from bci.evaluations.logic import TestParameters, TestResult, WorkerParameters -logger = logging.getLogger('bci') +logger = logging.getLogger(__name__) class EvaluationFramework(ABC): diff --git a/bci/evaluations/logic.py b/bci/evaluations/logic.py index dfb1358..25d51f6 100644 --- a/bci/evaluations/logic.py +++ b/bci/evaluations/logic.py @@ -8,7 +8,6 @@ import bci.browser.cli_options.chromium as cli_options_chromium import bci.browser.cli_options.firefox as cli_options_firefox -import bci.version_control.states.factory as state_factory from bci.version_control.states.state import State logger = logging.getLogger('bci') @@ -178,7 +177,7 @@ def _to_dict(self): return { 'browser_configuration': self.browser_configuration.to_dict(), 'evaluation_configuration': self.evaluation_configuration.to_dict(), - 'state': state_factory.to_dict(self.state), + 'state': self.state.to_dict(), 'mech_groups': self.mech_groups, 'database_collection': self.database_collection, 'database_connection_params': self.database_connection_params.to_dict() @@ -198,7 +197,7 @@ def deserialize(string: str) -> WorkerParameters: data = json.loads(string) browser_config = BrowserConfiguration.from_dict(data['browser_configuration']) eval_config = EvaluationConfiguration.from_dict(data['evaluation_configuration']) - state = state_factory.from_dict(data['state']) + state = State.from_dict(data['state']) mech_groups = data['mech_groups'] database_collection = data['database_collection'] database_connection_params = DatabaseConnectionParameters.from_dict(data['database_connection_params']) @@ -223,12 +222,12 @@ class TestParameters: mech_group: str database_collection: str - def create_test_result_with(self, browser_version: str, binary_origin: str, result: dict, dirty: bool) -> TestResult: + def create_test_result_with(self, browser_version: str, binary_origin: str, data: dict, dirty: bool) -> TestResult: return TestResult( self, browser_version, binary_origin, - result, + data, dirty ) @@ -238,7 +237,7 @@ class TestResult: params: TestParameters browser_version: str binary_origin: str - requests: list | None = None + data: dict is_dirty: bool = False driver_version: str | None = None @@ -252,6 +251,13 @@ def padded_browser_version(self): padded_version.append('0' * (padding_target - len(sub)) + sub) 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 + @dataclass(frozen=True) class PlotParameters: diff --git a/bci/evaluations/outcome_checker.py b/bci/evaluations/outcome_checker.py index 1c3fa14..556655e 100644 --- a/bci/evaluations/outcome_checker.py +++ b/bci/evaluations/outcome_checker.py @@ -11,16 +11,29 @@ def __init__(self, sequence_config: SequenceConfiguration): @abstractmethod def get_outcome(self, result: TestResult) -> bool: + ''' + Returns the outcome of the test result. + + - None in case of an error. + - True if the test was reproduced. + - False if the test was not reproduced. + ''' + if result.is_dirty: + return None + if result.reproduced: + return True + # Backwards compatibility if self.sequence_config.target_mech_id: return self.get_outcome_for_proxy(result) def get_outcome_for_proxy(self, result: TestResult) -> bool | None: target_mech_id = self.sequence_config.target_mech_id target_cookie = self.sequence_config.target_cookie_name - if result.requests is None: + requests = result.data.get('requests') + if requests is None: return None regex = rf'^https:\/\/[a-zA-Z0-9-]+\.[a-zA-Z]+\/report\/\?leak={target_mech_id}$' - requests_to_result_endpoint = [request for request in result.requests if re.match(regex, request['url'])] + requests_to_result_endpoint = [request for request in requests if re.match(regex, request['url'])] for request in requests_to_result_endpoint: headers = request['headers'] if not target_cookie: diff --git a/bci/main.py b/bci/main.py index e49bfee..72f64ed 100644 --- a/bci/main.py +++ b/bci/main.py @@ -1,9 +1,7 @@ -import dataclasses import logging -import os import bci.browser.binary.factory as binary_factory -from bci.browser.configuration import chromium, firefox +from bci.browser.support import get_chromium_support, get_firefox_support from bci.configuration import Global, Loggers from bci.database.mongo.mongodb import MongoDB from bci.evaluations.logic import EvaluationParameters, PlotParameters @@ -61,30 +59,12 @@ def get_database_info() -> dict: return MongoDB.get_info() @staticmethod - def get_browsers() -> list[str]: + def get_browser_support() -> list[dict]: return [ - 'chromium', - 'firefox' + get_chromium_support(), + get_firefox_support() ] - @staticmethod - def get_browser_options(browser_name: str) -> list[dict[str, str]]: - match browser_name: - case 'chromium': - return [dataclasses.asdict(option) for option in chromium.SUPPORTED_OPTIONS] - case 'firefox': - return [dataclasses.asdict(option) for option in firefox.SUPPORTED_OPTIONS] - case _: - raise AttributeError(f'Browser \'{browser_name}\' is not supported') - - @staticmethod - def get_available_extensions(browser: str) -> list[str]: - extensions = [] - folder_path = Global.get_extension_folder(browser) - for _, _, files in os.walk(folder_path): - extensions.extend(files) - return list(filter(lambda x: x != '.gitkeep', extensions)) - @staticmethod def list_downloaded_binaries(browser): return binary_factory.list_downloaded_binaries(browser) diff --git a/bci/master.py b/bci/master.py index a1fba22..9f83e1c 100644 --- a/bci/master.py +++ b/bci/master.py @@ -17,7 +17,7 @@ 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 state_factory +from bci.version_control import factory from bci.version_control.states.state import State logger = logging.getLogger(__name__) @@ -31,7 +31,7 @@ def __init__(self): self.stop_gracefully = False self.stop_forcefully = False - self.evaluations = [] + # self.evaluations = [] self.evaluation_framework = None self.worker_manager = None self.available_evaluation_frameworks = {} @@ -68,16 +68,14 @@ def run(self, eval_params: EvaluationParameters): worker_manager = WorkerManager(sequence_config.nb_of_containers) try: - state_list = state_factory.get_state_list(browser_config, evaluation_range) + state_list = factory.create_state_collection(browser_config, evaluation_range) - search_strategy = self.parse_search_strategy( - sequence_config.search_strategy, state_list, 2, sequence_config.sequence_limit - ) + 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) + # self.evaluations.append(state_list) try: current_state = search_strategy.next() @@ -89,7 +87,7 @@ def run(self, eval_params: EvaluationParameters): # Check whether state is already evaluated if self.evaluation_framework.has_all_results(worker_params): - logger.info(f"State '{current_state.revision_number}' already evaluated.") + logger.info(f"'{current_state}' already evaluated.") update_outcome() current_state = search_strategy.next() continue @@ -134,14 +132,16 @@ def inititialize_available_evaluation_frameworks(self): self.available_evaluation_frameworks["xsleaks"] = XSLeaksEvaluation() @staticmethod - def parse_search_strategy(search_strategy_option: str, state_list: list[State], n: int, sequence_limit: int): - if search_strategy_option == "bin_seq": - return NArySequence(state_list, n, limit=sequence_limit) - if search_strategy_option == "bin_search": - return NArySearch(state_list, n) - if search_strategy_option == "comp_search": - return CompositeSearch(state_list, n, sequence_limit, NArySequence, NArySearch) - raise AttributeError("Unknown search strategy option '%s'" % search_strategy_option) + def parse_search_strategy(sequence_config: SequenceConfiguration, state_list: list[State]): + 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 diff --git a/bci/search_strategy/composite_search.py b/bci/search_strategy/composite_search.py index c2feb39..60fbc23 100644 --- a/bci/search_strategy/composite_search.py +++ b/bci/search_strategy/composite_search.py @@ -46,15 +46,20 @@ def next_in_search_strategy(self) -> State: 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] else: - raise AttributeError("No strategy is currently active") + return None def update_outcome(self, elem: State, outcome: bool) -> None: - self.get_active_strategy().update_outcome(elem, outcome) + 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. diff --git a/bci/search_strategy/sequence_elem.py b/bci/search_strategy/sequence_elem.py index 20a1064..156714f 100644 --- a/bci/search_strategy/sequence_elem.py +++ b/bci/search_strategy/sequence_elem.py @@ -23,14 +23,16 @@ def __init__(self, index: int, value: State, state: ElemState = ElemState.INITIA self.outcome = outcome def is_available(self) -> bool: - return binary_factory.binary_is_available(self.value) + 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 - self.state = ElemState.DONE + else: + self.state = ElemState.DONE self.outcome = outcome def get_deep_copy(self, index=None): diff --git a/bci/search_strategy/sequence_strategy.py b/bci/search_strategy/sequence_strategy.py index 48c7e9c..74cc175 100644 --- a/bci/search_strategy/sequence_strategy.py +++ b/bci/search_strategy/sequence_strategy.py @@ -8,7 +8,7 @@ class SequenceStrategy: def __init__(self, values: list[State], prior_elems: list[SequenceElem] = None) -> None: - self.logger = logging.getLogger("bci") + 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 diff --git a/bci/ui/app.py b/bci/ui/app.py index 11a2d01..25a92c7 100644 --- a/bci/ui/app.py +++ b/bci/ui/app.py @@ -26,3 +26,9 @@ def index(): def serve_static_files(file_path): path = os.path.join('dist', file_path) return send_from_directory('frontend', path) + + +if __name__ == '__main__': + # Used when running in devcontainer + app = create_app() + app.run(debug=False, host='0.0.0.0') diff --git a/bci/ui/blueprints/api.py b/bci/ui/blueprints/api.py index 5a06671..47c6f5b 100644 --- a/bci/ui/blueprints/api.py +++ b/bci/ui/blueprints/api.py @@ -113,7 +113,7 @@ def get_info(): def get_browsers(): return { 'status': 'OK', - 'browsers': bci_api.get_browsers() + 'browsers': bci_api.get_browser_support() } @@ -125,34 +125,12 @@ def get_projects(): } -@api.route('/options//', methods=['GET']) -def get_options(browser_name: str): - try: - options = bci_api.get_browser_options(browser_name) - return { - 'status': 'OK', - 'options': options - } - except Exception as e: - return { - 'status': 'NOK', - 'msg': str(e) - } - - -@api.route('/extensions//', methods=['GET']) -def get_extensions(browser_name: str): - try: - extensions = bci_api.get_available_extensions(browser_name) - return { - 'status': 'OK', - 'extensions': extensions - } - except Exception as e: - return { - 'status': 'NOK', - 'msg': str(e) - } +@api.route('/system/', methods=['GET']) +def get_system_info(): + return { + 'status': 'OK', + 'cpu_count': os.cpu_count() if os.cpu_count() else 2 + } @api.route('/tests//', methods=['GET']) diff --git a/bci/ui/frontend/node_modules/resolve/test/resolver/symlinked/_/symlink_target/.gitkeep b/bci/ui/frontend/node_modules/resolve/test/resolver/symlinked/_/symlink_target/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/bci/ui/frontend/node_modules/tailwindcss/types/generated/.gitkeep b/bci/ui/frontend/node_modules/tailwindcss/types/generated/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/bci/ui/frontend/package-lock.json b/bci/ui/frontend/package-lock.json index d7b51d4..1cf4b37 100644 --- a/bci/ui/frontend/package-lock.json +++ b/bci/ui/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "app", "version": "0.0.0", "dependencies": { + "@vueform/slider": "^2.1.10", "axios": "^1.4.0", "flowbite": "^1.6.5", "oh-vue-icons": "^1.0.0-rc3", @@ -618,6 +619,11 @@ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.19.tgz", "integrity": "sha512-/KliRRHMF6LoiThEy+4c1Z4KB/gbPrGjWwJR+crg2otgrf/egKzRaCPvJ51S5oetgsgXLfc4Rm5ZgrKHZrtMSw==" }, + "node_modules/@vueform/slider": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@vueform/slider/-/slider-2.1.10.tgz", + "integrity": "sha512-L2G3Ju51Yq6yWF2wzYYsicUUaH56kL1QKGVtimUVHT1K1ADcRT94xVyIeJpS0klliVEeF6iMZFbdXtHq8AsDHw==" + }, "node_modules/ansi-regex": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", diff --git a/bci/ui/frontend/package.json b/bci/ui/frontend/package.json index bba8903..d74f717 100644 --- a/bci/ui/frontend/package.json +++ b/bci/ui/frontend/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@vueform/slider": "^2.1.10", "axios": "^1.4.0", "flowbite": "^1.6.5", "oh-vue-icons": "^1.0.0-rc3", diff --git a/bci/ui/frontend/src/App.vue b/bci/ui/frontend/src/App.vue index 403f7e0..dc06c9c 100644 --- a/bci/ui/frontend/src/App.vue +++ b/bci/ui/frontend/src/App.vue @@ -1,12 +1,15 @@ +