diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py new file mode 100644 index 00000000..c68a8c8e --- /dev/null +++ b/benchmarks/__init__.py @@ -0,0 +1,20 @@ +import importlib +import re + +from glob import glob + +test_types = {} +test_type_folders = glob("/BenchWeb/benchmarks/*/") + +# Loads all the test_types from the folders in this directory +for folder in test_type_folders: + # regex that grabs the characters between "benchmarks/" + # and the final "/" in the folder string to get the name + test_type_name = re.findall(r'.+\/(.+)\/$', folder, re.M)[0] + # ignore generated __pycache__ folder + if test_type_name == '__pycache__': + continue + spec = importlib.util.spec_from_file_location("TestType", "%s%s.py" % (folder, test_type_name)) + test_type = importlib.util.module_from_spec(spec) + spec.loader.exec_module(test_type) + test_types[test_type_name] = test_type.TestType diff --git a/benchmarks/abstract_test_type.py b/benchmarks/abstract_test_type.py new file mode 100644 index 00000000..5f181876 --- /dev/null +++ b/benchmarks/abstract_test_type.py @@ -0,0 +1,132 @@ +import abc +import copy +import requests + +from colorama import Fore +from utils.output_helper import log + +class AbstractTestType(metaclass=abc.ABCMeta): + ''' + Interface between a test type (json, query, plaintext, etc) and + the rest of BW. A test type defines a number of keys it expects + to find in the benchmark_config.json, and this base class handles extracting + those keys and injecting them into the test. For example, if + benchmark_config.json contains a line `"spam" : "foobar"` and a subclasses X + passes an argument list of ['spam'], then after parsing there will + exist a member `X.spam = 'foobar'`. + ''' + + def __init__(self, + config, + name, + requires_db=False, + accept_header=None, + args=[]): + self.config = config + self.name = name + self.requires_db = requires_db + self.args = args + self.headers = "" + self.body = "" + + if accept_header is None: + self.accept_header = self.accept('json') + else: + self.accept_header = accept_header + + self.passed = None + self.failed = None + self.warned = None + + @classmethod + def accept(self, content_type): + return { + 'json': + 'application/json,text/html;q=0.9,application/xhtml+xml;q=0.9,application/xml;q=0.8,*/*;q=0.7', + 'html': + 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'plaintext': + 'text/plain,text/html;q=0.9,application/xhtml+xml;q=0.9,application/xml;q=0.8,*/*;q=0.7' + }[content_type] + + def parse(self, test_keys): + ''' + Takes the dict of key/value pairs describing a FrameworkTest + and collects all variables needed by this AbstractTestType + + Raises AttributeError if required keys are missing + ''' + if all(arg in test_keys for arg in self.args): + self.__dict__.update({arg: test_keys[arg] for arg in self.args}) + return self + else: # This is quite common - most tests don't support all types + raise AttributeError( + "A %s requires the benchmark_config.json to contain %s" % + (self.name, self.args)) + + def request_headers_and_body(self, url): + ''' + Downloads a URL and returns the HTTP response headers + and body content as a tuple + ''' + log("Accessing URL {!s}: ".format(url), color=Fore.CYAN) + + headers = {'Accept': self.accept_header} + r = requests.get(url, timeout=15, headers=headers) + + self.headers = r.headers + self.body = r.content + return self.headers, self.body + + def output_headers_and_body(self): + log(str(self.headers)) + log(self.body) + + def verify(self, base_url): + ''' + Accesses URL used by this test type and checks the return + values for correctness. Most test types run multiple checks, + so this returns a list of results. Each result is a 3-tuple + of (String result, String reason, String urlTested). + + - result : 'pass','warn','fail' + - reason : Short human-readable reason if result was + warn or fail. Please do not print the response as part of this, + other parts of BW will do that based upon the current logging + settings if this method indicates a failure happened + - urlTested: The exact URL that was queried + + Subclasses should make a best-effort attempt to report as many + failures and warnings as they can to help users avoid needing + to run BW repeatedly while debugging + ''' + # TODO make String result into an enum to enforce + raise NotImplementedError("Subclasses must provide verify") + + def get_url(self): + ''' + Returns the URL for this test, like '/json' + ''' + # This is a method because each test type uses a different key + # for their URL so the base class can't know which arg is the URL + raise NotImplementedError("Subclasses must provide get_url") + + def get_script_name(self): + ''' + Returns the remote script name for running the benchmarking process. + ''' + raise NotImplementedError("Subclasses must provide get_script_name") + + def get_script_variables(self, name, url, port): + ''' + Returns the remote script variables for running the benchmarking process. + ''' + raise NotImplementedError( + "Subclasses must provide get_script_variables") + + def copy(self): + ''' + Returns a copy that can be safely modified. + Use before calling parse + ''' + return copy.copy(self) diff --git a/benchmarks/benchmarker.py b/benchmarks/benchmarker.py new file mode 100644 index 00000000..7d39661f --- /dev/null +++ b/benchmarks/benchmarker.py @@ -0,0 +1,350 @@ +import threading + +from docker.models.containers import Container +from utils.output_helper import log, FNULL +from utils.docker_helper import DockerHelper +from utils.time_logger import TimeLogger +from utils.metadata import Metadata +from utils.results import Results +from utils.audit import Audit + +import os +import subprocess +import traceback +import sys +import time +import shlex +from pprint import pprint + +from colorama import Fore +import numbers + + +class Benchmarker: + def __init__(self, config): + ''' + Initialize the benchmarker. + ''' + self.config = config + self.time_logger = TimeLogger() + self.metadata = Metadata(self) + self.audit = Audit(self) + + # a list of all tests for this run + self.tests = self.metadata.tests_to_run() + if self.config.reverse_order: + self.tests.reverse() + self.results = Results(self) + self.docker_helper = DockerHelper(self) + + self.last_test = False + + ########################################################################################## + # Public methods + ########################################################################################## + + def run(self): + ''' + This process involves setting up the client/server machines + with any necessary change. Then going through each test, + running their docker build and run, verifying the URLs, and + running benchmarks against them. + ''' + # Generate metadata + self.metadata.list_test_metadata() + + any_failed = False + # Run tests + log("Running Tests...", border='=') + + # build wrk and all databases needed for current run + self.docker_helper.build_wrk() + self.docker_helper.build_databases() + + with open(os.path.join(self.results.directory, 'benchmark.log'), + 'w') as benchmark_log: + for test in self.tests: + if self.tests.index(test) + 1 == len(self.tests): + self.last_test = True + log("Running Test: %s" % test.name, border='-') + with self.config.quiet_out.enable(): + if not self.__run_test(test, benchmark_log): + any_failed = True + # Load intermediate result from child process + self.results.load() + + # Parse results + if self.config.mode == "benchmark": + log("Parsing Results ...", border='=') + self.results.parse(self.tests) + + self.results.set_completion_time() + self.results.upload() + self.results.finish() + + return any_failed + + def stop(self, signal=None, frame=None): + log("Shutting down (may take a moment)") + self.docker_helper.stop() + sys.exit(0) + + ########################################################################################## + # Private methods + ########################################################################################## + + def __exit_test(self, success, prefix, file, message=None): + if message: + log(message, + prefix=prefix, + file=file, + color=Fore.RED if success else '') + self.time_logger.log_test_end(log_prefix=prefix, file=file) + if self.config.mode == "benchmark": + # Sleep for 60 seconds to ensure all host connects are closed + log("Clean up: Sleep 60 seconds...", prefix=prefix, file=file) + time.sleep(60) + # After benchmarks are complete for all test types in this test, + # let's clean up leftover test images (khulnasoft/bw.test.test-name) + self.docker_helper.clean() + + return success + + def __run_test(self, test, benchmark_log): + ''' + Runs the given test, verifies that the webapp is accepting requests, + optionally benchmarks the webapp, and ultimately stops all services + started for this test. + ''' + + log_prefix = "%s: " % test.name + # Start timing the total test duration + self.time_logger.mark_test_start() + + if self.config.mode == "benchmark": + log("Benchmarking %s" % test.name, + file=benchmark_log, + border='-') + + # If the test is in the excludes list, we skip it + if self.config.exclude and test.name in self.config.exclude: + message = "Test {name} has been added to the excludes list. Skipping.".format( + name=test.name) + self.results.write_intermediate(test.name, message) + self.results.upload() + return self.__exit_test( + success=False, + message=message, + prefix=log_prefix, + file=benchmark_log) + + database_container = None + try: + # Start database container + if test.database.lower() != "none": + self.time_logger.mark_starting_database() + database_container = self.docker_helper.start_database( + test.database.lower()) + if database_container is None: + message = "ERROR: Problem building/running database container" + self.results.write_intermediate(test.name, message) + self.results.upload() + return self.__exit_test( + success=False, + message=message, + prefix=log_prefix, + file=benchmark_log) + self.time_logger.mark_started_database() + + # Start webapp + container = test.start() + self.time_logger.mark_test_starting() + if container is None: + message = "ERROR: Problem starting {name}".format( + name=test.name) + self.results.write_intermediate(test.name, message) + self.results.upload() + return self.__exit_test( + success=False, + message=message, + prefix=log_prefix, + file=benchmark_log) + + max_time = time.time() + 60 + while True: + accepting_requests = test.is_accepting_requests() + if accepting_requests \ + or time.time() >= max_time \ + or not self.docker_helper.server_container_exists(container.id): + break + time.sleep(1) + + if hasattr(test, 'wait_before_sending_requests') and isinstance(test.wait_before_sending_requests, numbers.Integral) and test.wait_before_sending_requests > 0: + time.sleep(test.wait_before_sending_requests) + + if not accepting_requests: + message = "ERROR: Framework is not accepting requests from client machine" + self.results.write_intermediate(test.name, message) + self.results.upload() + return self.__exit_test( + success=False, + message=message, + prefix=log_prefix, + file=benchmark_log) + + self.time_logger.mark_test_accepting_requests() + + # Debug mode blocks execution here until ctrl+c + if self.config.mode == "debug": + msg = "Entering debug mode. Server http://localhost:%s has started. CTRL-c to stop.\r\n" % test.port + msg = msg + "From outside vagrant: http://localhost:%s" % (int(test.port) + 20000) + log(msg, + prefix=log_prefix, + file=benchmark_log, + color=Fore.YELLOW) + while True: + time.sleep(1) + + # Verify URLs and audit + log("Verifying framework URLs", prefix=log_prefix) + self.time_logger.mark_verify_start() + passed_verify = test.verify_urls() + self.audit.audit_test_dir(test.directory) + + # Benchmark this test + if self.config.mode == "benchmark": + self.time_logger.mark_benchmarking_start() + self.__benchmark(test, benchmark_log) + self.time_logger.log_benchmarking_end( + log_prefix=log_prefix, file=benchmark_log) + + # Log test timing stats + self.time_logger.log_build_flush(benchmark_log) + self.time_logger.log_database_start_time(log_prefix, benchmark_log) + self.time_logger.log_test_accepting_requests( + log_prefix, benchmark_log) + self.time_logger.log_verify_end(log_prefix, benchmark_log) + + # Save results thus far into the latest results directory + self.results.write_intermediate(test.name, + time.strftime( + "%Y%m%d%H%M%S", + time.localtime())) + + # Upload the results thus far to another server (optional) + self.results.upload() + + if self.config.mode == "verify" and not passed_verify: + return self.__exit_test( + success=False, + message="Failed verify!", + prefix=log_prefix, + file=benchmark_log) + except Exception as e: + tb = traceback.format_exc() + self.results.write_intermediate(test.name, + "error during test: " + str(e)) + self.results.upload() + log(tb, prefix=log_prefix, file=benchmark_log) + return self.__exit_test( + success=False, + message="Error during test: %s" % test.name, + prefix=log_prefix, + file=benchmark_log) + finally: + self.docker_helper.stop() + + return self.__exit_test( + success=True, prefix=log_prefix, file=benchmark_log) + + def __benchmark(self, framework_test, benchmark_log): + ''' + Runs the benchmark for each type of test that it implements + ''' + + def benchmark_type(test_type): + log("BENCHMARKING %s ... " % test_type.upper(), file=benchmark_log) + + test = framework_test.runTests[test_type] + + if not test.failed: + # Begin resource usage metrics collection + self.__begin_logging(framework_test, test_type) + + script = self.config.types[test_type].get_script_name() + script_variables = self.config.types[ + test_type].get_script_variables( + test.name, "http://%s:%s%s" % (self.config.server_host, + framework_test.port, + test.get_url())) + + benchmark_container = self.docker_helper.benchmark(script, script_variables) + self.__log_container_output(benchmark_container, framework_test, test_type) + + # End resource usage metrics collection + self.__end_logging() + + results = self.results.parse_test(framework_test, test_type) + log("Benchmark results:", file=benchmark_log) + # TODO move into log somehow + pprint(results) + + self.results.report_benchmark_results(framework_test, test_type, + results['results']) + log("Complete", file=benchmark_log) + + for test_type in framework_test.runTests: + benchmark_type(test_type) + + def __begin_logging(self, framework_test, test_type): + ''' + Starts a thread to monitor the resource usage, to be synced with the + client's time. + TODO: MySQL and InnoDB are possible. Figure out how to implement them. + ''' + output_file = "{file_name}".format( + file_name=self.results.get_stats_file(framework_test.name, + test_type)) + dool_string = "dool -Tafilmprs --aio --fs --ipc --lock --socket --tcp \ + --raw --udp --unix --vm --disk-util \ + --rpc --rpcd --output {output_file}".format( + output_file=output_file) + cmd = shlex.split(dool_string) + self.subprocess_handle = subprocess.Popen( + cmd, stdout=FNULL, stderr=subprocess.STDOUT) + + def __end_logging(self): + ''' + Stops the logger thread and blocks until shutdown is complete. + ''' + self.subprocess_handle.terminate() + self.subprocess_handle.communicate() + + def __log_container_output(self, container: Container, framework_test, test_type) -> None: + def save_docker_logs(stream): + raw_file_path = self.results.get_raw_file(framework_test.name, test_type) + with open(raw_file_path, 'w') as file: + for line in stream: + log(line.decode(), file=file) + + def save_docker_stats(stream): + docker_file_path = self.results.get_docker_stats_file(framework_test.name, test_type) + with open(docker_file_path, 'w') as file: + file.write('[\n') + is_first_line = True + for line in stream: + if is_first_line: + is_first_line = False + else: + file.write(',') + file.write(line.decode()) + file.write(']') + + threads = [ + threading.Thread(target=lambda: save_docker_logs(container.logs(stream=True))), + threading.Thread(target=lambda: save_docker_stats(container.stats(stream=True))) + ] + + [thread.start() for thread in threads] + [thread.join() for thread in threads] + diff --git a/benchmarks/cached-query/cached-query.py b/benchmarks/cached-query/cached-query.py new file mode 100644 index 00000000..ea935d6b --- /dev/null +++ b/benchmarks/cached-query/cached-query.py @@ -0,0 +1,67 @@ +from benchmarks.test_types.abstract_test_type import AbstractTestType +from benchmarks.test_types.verifications import verify_query_cases + + +class TestType(AbstractTestType): + def __init__(self, config): + self.cached_query_url = "" + kwargs = { + 'name': 'cached-query', + 'accept_header': self.accept('json'), + 'requires_db': True, + 'args': ['cached_query_url'] + } + AbstractTestType.__init__(self, config, **kwargs) + + def get_url(self): + return self.cached_query_url + + def verify(self, base_url): + ''' + Validates the response is a JSON array of + the proper length, each JSON Object in the array + has keys 'id' and 'randomNumber', and these keys + map to integers. Case insensitive and + quoting style is ignored + ''' + + url = base_url + self.cached_query_url + cases = [('2', 'fail'), ('0', 'fail'), ('foo', 'fail'), + ('501', 'warn'), ('', 'fail')] + + problems = verify_query_cases(self, cases, url) + + # cached_query_url should be at least "/cached-worlds/" + # some frameworks use a trailing slash while others use ?q= + if len(self.cached_query_url) < 15: + problems.append( + ("fail", + "Route for cached queries must be at least 15 characters, found '{}' instead".format(self.cached_query_url), + url)) + + if len(problems) == 0: + return [('pass', '', url + case) for case, _ in cases] + else: + return problems + + def get_script_name(self): + return 'query.sh' + + def get_script_variables(self, name, url): + return { + 'max_concurrency': + max(self.config.concurrency_levels), + 'name': + name, + 'duration': + self.config.duration, + 'levels': + " ".join( + "{}".format(item) for item in self.config.cached_query_levels), + 'server_host': + self.config.server_host, + 'url': + url, + 'accept': + "application/json,text/html;q=0.9,application/xhtml+xml;q=0.9,application/xml;q=0.8,*/*;q=0.7" + } diff --git a/benchmarks/db/db.py b/benchmarks/db/db.py new file mode 100644 index 00000000..7b732abd --- /dev/null +++ b/benchmarks/db/db.py @@ -0,0 +1,94 @@ +from benchmarks.test_types.abstract_test_type import AbstractTestType +from benchmarks.test_types.verifications import basic_body_verification, verify_headers, verify_randomnumber_object, verify_queries_count + + +class TestType(AbstractTestType): + def __init__(self, config): + self.db_url = "" + kwargs = { + 'name': 'db', + 'accept_header': self.accept('json'), + 'requires_db': True, + 'args': ['db_url', 'database'] + } + AbstractTestType.__init__(self, config, **kwargs) + + def get_url(self): + return self.db_url + + def verify(self, base_url): + ''' + Ensures body is valid JSON with a key 'id' and a key + 'randomNumber', both of which must map to integers + ''' + + # Initialization for query counting + repetitions = 1 + concurrency = max(self.config.concurrency_levels) + expected_queries = repetitions * concurrency + + url = base_url + self.db_url + headers, body = self.request_headers_and_body(url) + + response, problems = basic_body_verification(body, url) + + # db should be at least "/db" + if len(self.db_url) < 3: + problems.append( + ("fail", + "Route for db must be at least 3 characters, found '{}' instead".format(self.db_url), + url)) + + if len(problems) > 0: + return problems + + # We are allowing the single-object array + # e.g. [{'id':5, 'randomNumber':10}] for now, + # but will likely make this fail at some point + if type(response) == list: + response = response[0] + problems.append(( + 'warn', + 'Response is a JSON array. Expected JSON object (e.g. [] vs {})', + url)) + + # Make sure there was a JSON object inside the array + if type(response) != dict: + problems.append(( + 'fail', + 'Response is not a JSON object or an array of JSON objects', + url)) + return problems + + # Verify response content + problems += verify_randomnumber_object(response, url) + problems += verify_headers(self.request_headers_and_body, headers, url, should_be='json') + + if len(problems) == 0: + problems += verify_queries_count(self, "World", url, concurrency, repetitions, expected_queries, expected_queries) + if len(problems) == 0: + return [('pass', '', url)] + else: + return problems + + def get_script_name(self): + return 'concurrency.sh' + + def get_script_variables(self, name, url): + return { + 'max_concurrency': + max(self.config.concurrency_levels), + 'name': + name, + 'duration': + self.config.duration, + 'levels': + " ".join( + "{}".format(item) for item in self.config.concurrency_levels), + 'server_host': + self.config.server_host, + 'url': + url, + 'accept': + "application/json,text/html;q=0.9,application/xhtml+xml;q=0.9,application/xml;q=0.8,*/*;q=0.7" + } diff --git a/benchmarks/fortune/__init__.py b/benchmarks/fortune/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/benchmarks/fortune/fortune.py b/benchmarks/fortune/fortune.py new file mode 100644 index 00000000..27882b6c --- /dev/null +++ b/benchmarks/fortune/fortune.py @@ -0,0 +1,123 @@ +from benchmarks.test_types.abstract_test_type import AbstractTestType +from benchmarks.test_types.fortune.fortune_html_parser import FortuneHTMLParser +from benchmarks.test_types.verifications import basic_body_verification, verify_headers, verify_queries_count + + +class TestType(AbstractTestType): + def __init__(self, config): + self.fortune_url = "" + kwargs = { + 'name': 'fortune', + 'accept_header': self.accept('html'), + 'requires_db': True, + 'args': ['fortune_url','database'] + } + AbstractTestType.__init__(self, config, **kwargs) + + def get_url(self): + return self.fortune_url + + def verify(self, base_url): + ''' + Parses the given HTML string and asks the + FortuneHTMLParser whether the parsed string is a + valid fortune response + ''' + # Initialization for query counting + repetitions = 1 + concurrency = max(self.config.concurrency_levels) + expected_queries = repetitions * concurrency + expected_rows = 12 * expected_queries + + url = base_url + self.fortune_url + headers, body = self.request_headers_and_body(url) + + _, problems = basic_body_verification(body, url, is_json_check=False) + + # fortune_url should be at least "/fortunes" + if len(self.fortune_url) < 9: + problems.append( + ("fail", + "Route for fortunes must be at least 9 characters, found '{}' instead".format(self.fortune_url), + url)) + + if len(problems) > 0: + return problems + + parser = FortuneHTMLParser() + parser.feed(body.decode()) + (valid, diff) = parser.isValidFortune(self.name, body.decode()) + + if valid: + problems += verify_headers(self.request_headers_and_body, headers, url, should_be='html') + if len(problems) == 0: + problems += verify_queries_count(self, "fortune", url, concurrency, repetitions, expected_queries, expected_rows) + if len(problems) == 0: + return [('pass', '', url)] + else: + return problems + else: + failures = [] + failures.append(('fail', 'Invalid according to FortuneHTMLParser', + url)) + failures += self._parseDiffForFailure(diff, failures, url) + return failures + + def _parseDiffForFailure(self, diff, failures, url): + ''' + Example diff: + + --- Valid + +++ Response + @@ -1 +1 @@ + + -Fortunes + +Fortunes
+ @@ -16 +16 @@ + ''' + + problems = [] + + # Catch exceptions because we are relying on internal code + try: + current_neg = [] + current_pos = [] + for line in diff[3:]: + if line[0] == '+': + current_neg.append(line[1:]) + elif line[0] == '-': + current_pos.append(line[1:]) + elif line[0] == '@': + problems.append(('fail', "`%s` should be `%s`" % + (''.join(current_neg), + ''.join(current_pos)), url)) + if len(current_pos) != 0: + problems.append(('fail', "`%s` should be `%s`" % + (''.join(current_neg), + ''.join(current_pos)), url)) + except: + # If there were errors reading the diff, then no diff information + pass + return problems + + def get_script_name(self): + return 'concurrency.sh' + + def get_script_variables(self, name, url): + return { + 'max_concurrency': + max(self.config.concurrency_levels), + 'name': + name, + 'duration': + self.config.duration, + 'levels': + " ".join( + "{}".format(item) for item in self.config.concurrency_levels), + 'server_host': + self.config.server_host, + 'url': + url, + 'accept': + "application/json,text/html;q=0.9,application/xhtml+xml;q=0.9,application/xml;q=0.8,*/*;q=0.7" + } diff --git a/benchmarks/fortune/fortune_html_parser.py b/benchmarks/fortune/fortune_html_parser.py new file mode 100644 index 00000000..a84e645f --- /dev/null +++ b/benchmarks/fortune/fortune_html_parser.py @@ -0,0 +1,189 @@ +# -*- coding: utf-8 +import os + +from html.parser import HTMLParser +from difflib import unified_diff + +from utils.output_helper import log + + +class FortuneHTMLParser(HTMLParser): + def __init__(self): + HTMLParser.__init__(self, convert_charrefs=False) + self.body = [] + + valid_fortune = ''' +Fortunes +
+ + + + + + + + + + + + + + +
idmessage
11<script>alert("This should not be displayed in a browser alert box.");</script>
4A bad random number generator: 1, 1, 1, 1, 1, 4.33e+67, 1, 1, 1
5A computer program does what you tell it to do, not what you want it to do.
2A computer scientist is someone who fixes things that aren't broken.
8A list is only as strong as its weakest link. — Donald Knuth
0Additional fortune added at request time.
3After enough decimal places, nobody gives a damn.
7Any program that runs right is obsolete.
10Computers make very fast, very accurate mistakes.
6Emacs is a nice operating system, but I prefer UNIX. — Tom Christaensen
9Feature: A bug with seniority.
1fortune: No such file or directory
12フレームワークのベンチマーク
''' + + def handle_decl(self, decl): + ''' + Is called when a doctype or other such tag is read in. + For our purposes, we assume this is only going to be + "DOCTYPE html", so we will surround it with "". + ''' + # The spec says that for HTML this is case insensitive, + # and since we did not specify xml compliance (where + # incorrect casing would throw a syntax error), we must + # allow all casings. We will lower for our normalization. + self.body.append("".format(d=decl.lower())) + + def handle_charref(self, name): + ''' + This is called when an HTML character is parsed (i.e. + "). There are a number of issues to be resolved + here. For instance, some tests choose to leave the + "+" character as-is, which should be fine as far as + character escaping goes, but others choose to use the + character reference of "+", which is also fine. + Therefore, this method looks for all possible character + references and normalizes them so that we can + validate the input against a single valid spec string. + Another example problem: """ is valid, but so is + """ + ''' + val = name.lower() + # """ is a valid escaping, but we are normalizing + # it so that our final parse can just be checked for + # equality. + if val == "34" or val == "034" or val == "x22": + # Append our normalized entity reference to our body. + self.body.append(""") + # "'" is a valid escaping of "-", but it is not + # required, so we normalize for equality checking. + if val == "39" or val == "039" or val == "x27": + self.body.append("'") + # Again, "+" is a valid escaping of the "+", but + # it is not required, so we need to normalize for out + # final parse and equality check. + if val == "43" or val == "043" or val == "x2b": + self.body.append("+") + # Again, ">" is a valid escaping of ">", but we + # need to normalize to ">" for equality checking. + if val == "62" or val == "062" or val == "x3e": + self.body.append(">") + # Again, "<" is a valid escaping of "<", but we + # need to normalize to "<" for equality checking. + if val == "60" or val == "060" or val == "x3c": + self.body.append("<") + # Not sure why some are escaping '/' + if val == "47" or val == "047" or val == "x2f": + self.body.append("/") + # "(" is a valid escaping of "(", but + # it is not required, so we need to normalize for out + # final parse and equality check. + if val == "40" or val == "040" or val == "x28": + self.body.append("(") + # ")" is a valid escaping of ")", but + # it is not required, so we need to normalize for out + # final parse and equality check. + if val == "41" or val == "041" or val == "x29": + self.body.append(")") + + def handle_entityref(self, name): + ''' + Again, "—" is a valid escaping of "—", but we + need to normalize to "—" for equality checking. + ''' + if name == "mdash": + self.body.append("—") + else: + self.body.append("&{n};".format(n=name)) + + def handle_starttag(self, tag, attrs): + ''' + This is called every time a tag is opened. We append + each one wrapped in "<" and ">". + ''' + self.body.append("<{t}>".format(t=tag)) + + # Append a newline after the and + if tag.lower() == 'table' or tag.lower() == 'html': + self.body.append(os.linesep) + + def handle_data(self, data): + ''' + This is called whenever data is presented inside of a + start and end tag. Generally, this will only ever be + the contents inside of "", but there + are also the "" and "" tags. + ''' + if data.strip() != '': + # After a LOT of debate, these are now considered + # valid in data. The reason for this approach is + # because a few tests use tools which determine + # at compile time whether or not a string needs + # a given type of html escaping, and our fortune + # test has apostrophes and quotes in html data + # rather than as an html attribute etc. + # example: + # + # Semanticly, that apostrophe does not NEED to + # be escaped. The same is currently true for our + # quotes. + # In fact, in data (read: between two html tags) + # even the '>' need not be replaced as long as + # the '<' are all escaped. + # We replace them with their escapings here in + # order to have a noramlized string for equality + # comparison at the end. + data = data.replace('\'', ''') + data = data.replace('"', '"') + data = data.replace('>', '>') + + self.body.append("{d}".format(d=data)) + + def handle_endtag(self, tag): + ''' + This is called every time a tag is closed. We append + each one wrapped in "". + ''' + self.body.append("".format(t=tag)) + + # Append a newline after each and + if tag.lower() == 'tr' or tag.lower() == 'head': + self.body.append(os.linesep) + + def isValidFortune(self, name, out): + ''' + Returns whether the HTML input parsed by this parser + is valid against our known "fortune" spec. + The parsed data in 'body' is joined on empty strings + and checked for equality against our spec. + ''' + body = ''.join(self.body) + same = self.valid_fortune == body + diff_lines = [] + if not same: + output = "Oh no! I compared {!s}".format(self.valid_fortune) + output += os.linesep + os.linesep + "to" + os.linesep + os.linesep + body + os.linesep + output += "Fortune invalid. Diff following:" + os.linesep + headers_left = 3 + for line in unified_diff( + self.valid_fortune.split(os.linesep), + body.split(os.linesep), + fromfile='Valid', + tofile='Response', + n=0): + diff_lines.append(line) + output += line + headers_left -= 1 + if headers_left <= 0: + output += os.linesep + log(output, prefix="%s: " % name) + return (same, diff_lines) diff --git a/benchmarks/framework_test.py b/benchmarks/framework_test.py new file mode 100644 index 00000000..83713db3 --- /dev/null +++ b/benchmarks/framework_test.py @@ -0,0 +1,189 @@ +import os +import traceback +from requests import ConnectionError, Timeout + +from utils.output_helper import log + +# Cross-platform colored text +from colorama import Fore, Style + + +class FrameworkTest: + def __init__(self, name, directory, benchmarker, runTests, + args): + ''' + Constructor + ''' + self.name = name + self.directory = directory + self.benchmarker = benchmarker + self.runTests = runTests + self.approach = "" + self.classification = "" + self.database = "" + self.framework = "" + self.language = "" + self.orm = "" + self.platform = "" + self.webserver = "" + self.os = "" + self.database_os = "" + self.display_name = "" + self.notes = "" + self.port = "" + self.versus = "" + + self.__dict__.update(args) + + ########################################################################################## + # Public Methods + ########################################################################################## + + def start(self): + ''' + Start the test implementation + ''' + test_log_dir = os.path.join(self.benchmarker.results.directory, self.name.lower()) + build_log_dir = os.path.join(test_log_dir, 'build') + run_log_dir = os.path.join(test_log_dir, 'run') + + try: + os.makedirs(build_log_dir) + except OSError: + pass + try: + os.makedirs(run_log_dir) + except OSError: + pass + + result = self.benchmarker.docker_helper.build(self, build_log_dir) + if result != 0: + return None + + return self.benchmarker.docker_helper.run(self, run_log_dir) + + def is_accepting_requests(self): + ''' + Determines whether this test implementation is up and accepting + requests. + ''' + test_type = None + for any_type in self.runTests: + test_type = any_type + break + + url = "http://%s:%s%s" % (self.benchmarker.config.server_host, + self.port, + self.runTests[test_type].get_url()) + + return self.benchmarker.docker_helper.test_client_connection(url) + + def verify_urls(self): + ''' + Verifys each of the URLs for this test. This will simply curl the URL and + check for it's return status. For each url, a flag will be set on this + object for whether or not it passed. + Returns True if all verifications succeeded + ''' + log_path = os.path.join(self.benchmarker.results.directory, self.name.lower()) + result = True + + def verify_type(test_type): + verificationPath = os.path.join(log_path, test_type) + try: + os.makedirs(verificationPath) + except OSError: + pass + with open(os.path.join(verificationPath, 'verification.txt'), + 'w') as verification: + test = self.runTests[test_type] + log("VERIFYING %s" % test_type.upper(), + file=verification, + border='-', + color=Fore.WHITE + Style.BRIGHT) + + base_url = "http://%s:%s" % ( + self.benchmarker.config.server_host, self.port) + + try: + # Verifies headers from the server. This check is made from the + # App Server using Pythons requests module. Will do a second check from + # the client to make sure the server isn't only accepting connections + # from localhost on a multi-machine setup. + results = test.verify(base_url) + + # Now verify that the url is reachable from the client machine, unless + # we're already failing + if not any(result == 'fail' + for (result, reason, url) in results): + self.benchmarker.docker_helper.test_client_connection( + base_url + test.get_url()) + except ConnectionError as e: + results = [('fail', "Server did not respond to request", + base_url)] + log("Verifying test %s for %s caused an exception: %s" % + (test_type, self.name, e), + color=Fore.RED) + except Timeout as e: + results = [('fail', "Connection to server timed out", + base_url)] + log("Verifying test %s for %s caused an exception: %s" % + (test_type, self.name, e), + color=Fore.RED) + except Exception as e: + results = [('fail', """Caused Exception in BW + This almost certainly means your return value is incorrect, + but also that you have found a bug. Please submit an issue + including this message: %s\n%s""" % (e, traceback.format_exc()), + base_url)] + log("Verifying test %s for %s caused an exception: %s" % + (test_type, self.name, e), + color=Fore.RED) + traceback.format_exc() + + test.failed = any( + result == 'fail' for (result, reason, url) in results) + test.warned = any( + result == 'warn' for (result, reason, url) in results) + test.passed = all( + result == 'pass' for (result, reason, url) in results) + + def output_result(result, reason, url): + specific_rules_url = "https://github.com/KhulnaSoft/BenchWeb/wiki/Project-Information-Framework-Tests-Overview#specific-test-requirements" + color = Fore.GREEN + if result.upper() == "WARN": + color = Fore.YELLOW + elif result.upper() == "FAIL": + color = Fore.RED + + log(" {!s}{!s}{!s} for {!s}".format( + color, result.upper(), Style.RESET_ALL, url), + file=verification) + if reason is not None and len(reason) != 0: + for line in reason.splitlines(): + log(" " + line, file=verification) + if not test.passed: + log(" See {!s}".format(specific_rules_url), + file=verification) + + [output_result(r1, r2, url) for (r1, r2, url) in results] + + if test.failed: + test.output_headers_and_body() + self.benchmarker.results.report_verify_results(self, test_type, 'fail') + elif test.warned: + test.output_headers_and_body() + self.benchmarker.results.report_verify_results(self, test_type, 'warn') + elif test.passed: + self.benchmarker.results.report_verify_results(self, test_type, 'pass') + else: + raise Exception( + "Unknown error - test did not pass,warn,or fail") + + result = True + for test_type in self.runTests: + verify_type(test_type) + if self.runTests[test_type].failed: + result = False + + return result diff --git a/benchmarks/json/json.py b/benchmarks/json/json.py new file mode 100644 index 00000000..e80692bb --- /dev/null +++ b/benchmarks/json/json.py @@ -0,0 +1,68 @@ +from benchmarks.test_types.abstract_test_type import AbstractTestType +from benchmarks.test_types.verifications import basic_body_verification, verify_headers, verify_helloworld_object + +class TestType(AbstractTestType): + def __init__(self, config): + self.json_url = "" + kwargs = { + 'name': 'json', + 'accept_header': self.accept('json'), + 'requires_db': False, + 'args': ['json_url'] + } + AbstractTestType.__init__(self, config, **kwargs) + + def get_url(self): + return self.json_url + + def verify(self, base_url): + ''' + Validates the response is a JSON object of + { 'message' : 'hello, world!' }. Case insensitive and + quoting style is ignored + ''' + + url = base_url + self.json_url + headers, body = self.request_headers_and_body(url) + + response, problems = basic_body_verification(body, url) + + # json_url should be at least "/json" + if len(self.json_url) < 5: + problems.append( + ("fail", + "Route for json must be at least 5 characters, found '{}' instead".format(self.json_url), + url)) + + if len(problems) > 0: + return problems + + problems += verify_helloworld_object(response, url) + problems += verify_headers(self.request_headers_and_body, headers, url, should_be='json') + + if len(problems) > 0: + return problems + else: + return [('pass', '', url)] + + def get_script_name(self): + return 'concurrency.sh' + + def get_script_variables(self, name, url): + return { + 'max_concurrency': + max(self.config.concurrency_levels), + 'name': + name, + 'duration': + self.config.duration, + 'levels': + " ".join( + "{}".format(item) for item in self.config.concurrency_levels), + 'server_host': + self.config.server_host, + 'url': + url, + 'accept': + "application/json,text/html;q=0.9,application/xhtml+xml;q=0.9,application/xml;q=0.8,*/*;q=0.7" + } diff --git a/benchmarks/load-testing/wrk/concurrency.sh b/benchmarks/load-testing/wrk/concurrency.sh new file mode 100644 index 00000000..c6b84271 --- /dev/null +++ b/benchmarks/load-testing/wrk/concurrency.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +let max_threads=$(nproc) +echo "" +echo "---------------------------------------------------------" +echo " Running Primer $name" +echo " wrk -H 'Host: $server_host' -H 'Accept: $accept' -H 'Connection: keep-alive' --latency -d 5 -c 8 --timeout 8 -t 8 $url" +echo "---------------------------------------------------------" +echo "" +wrk -H "Host: $server_host" -H "Accept: $accept" -H "Connection: keep-alive" --latency -d 5 -c 8 --timeout 8 -t 8 $url +sleep 5 + +echo "" +echo "---------------------------------------------------------" +echo " Running Warmup $name" +echo " wrk -H 'Host: $server_host' -H 'Accept: $accept' -H 'Connection: keep-alive' --latency -d $duration -c $max_concurrency --timeout 8 -t $max_threads \"$url\"" +echo "---------------------------------------------------------" +echo "" +wrk -H "Host: $server_host" -H "Accept: $accept" -H "Connection: keep-alive" --latency -d $duration -c $max_concurrency --timeout 8 -t $max_threads $url +sleep 5 + +for c in $levels +do +echo "" +echo "---------------------------------------------------------" +echo " Concurrency: $c for $name" +echo " wrk -H 'Host: $server_host' -H 'Accept: $accept' -H 'Connection: keep-alive' --latency -d $duration -c $c --timeout 8 -t $(($c>$max_threads?$max_threads:$c)) \"$url\"" +echo "---------------------------------------------------------" +echo "" +STARTTIME=$(date +"%s") +wrk -H "Host: $server_host" -H "Accept: $accept" -H "Connection: keep-alive" --latency -d $duration -c $c --timeout 8 -t "$(($c>$max_threads?$max_threads:$c))" $url +echo "STARTTIME $STARTTIME" +echo "ENDTIME $(date +"%s")" +sleep 2 +done diff --git a/benchmarks/load-testing/wrk/pipeline.lua b/benchmarks/load-testing/wrk/pipeline.lua new file mode 100644 index 00000000..850e5a71 --- /dev/null +++ b/benchmarks/load-testing/wrk/pipeline.lua @@ -0,0 +1,12 @@ +init = function(args) + local r = {} + local depth = tonumber(args[1]) or 1 + for i=1,depth do + r[i] = wrk.format() + end + req = table.concat(r) +end + +request = function() + return req +end \ No newline at end of file diff --git a/benchmarks/load-testing/wrk/pipeline.sh b/benchmarks/load-testing/wrk/pipeline.sh new file mode 100644 index 00000000..125318a8 --- /dev/null +++ b/benchmarks/load-testing/wrk/pipeline.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +let max_threads=$(nproc) +echo "" +echo "---------------------------------------------------------" +echo " Running Primer $name" +echo " wrk -H 'Host: $server_host' -H 'Accept: $accept' -H 'Connection: keep-alive' --latency -d 5 -c 8 --timeout 8 -t 8 $url" +echo "---------------------------------------------------------" +echo "" +wrk -H "Host: $server_host" -H "Accept: $accept" -H "Connection: keep-alive" --latency -d 5 -c 8 --timeout 8 -t 8 $url +sleep 5 + +echo "" +echo "---------------------------------------------------------" +echo " Running Warmup $name" +echo " wrk -H 'Host: $server_host' -H 'Accept: $accept' -H 'Connection: keep-alive' --latency -d $duration -c $max_concurrency --timeout 8 -t $max_threads $url" +echo "---------------------------------------------------------" +echo "" +wrk -H "Host: $server_host" -H "Accept: $accept" -H "Connection: keep-alive" --latency -d $duration -c $max_concurrency --timeout 8 -t $max_threads $url +sleep 5 + +for c in $levels +do +echo "" +echo "---------------------------------------------------------" +echo " Concurrency: $c for $name" +echo " wrk -H 'Host: $server_host' -H 'Accept: $accept' -H 'Connection: keep-alive' --latency -d $duration -c $c --timeout 8 -t $(($c>$max_threads?$max_threads:$c)) $url -s pipeline.lua -- $pipeline" +echo "---------------------------------------------------------" +echo "" +STARTTIME=$(date +"%s") +wrk -H "Host: $server_host" -H "Accept: $accept" -H "Connection: keep-alive" --latency -d $duration -c $c --timeout 8 -t "$(($c>$max_threads?$max_threads:$c))" $url -s pipeline.lua -- $pipeline +echo "STARTTIME $STARTTIME" +echo "ENDTIME $(date +"%s")" +sleep 2 +done diff --git a/benchmarks/load-testing/wrk/query.sh b/benchmarks/load-testing/wrk/query.sh new file mode 100644 index 00000000..1a1dbf64 --- /dev/null +++ b/benchmarks/load-testing/wrk/query.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +let max_threads=$(nproc) +echo "" +echo "---------------------------------------------------------" +echo " Running Primer $name" +echo " wrk -H 'Host: $server_host' -H 'Accept: $accept' -H 'Connection: keep-alive' --latency -d 5 -c 8 --timeout 8 -t 8 \"${url}2\"" +echo "---------------------------------------------------------" +echo "" +wrk -H "Host: $server_host" -H "Accept: $accept" -H "Connection: keep-alive" --latency -d 5 -c 8 --timeout 8 -t 8 "${url}2" +sleep 5 + +echo "" +echo "---------------------------------------------------------" +echo " Running Warmup $name" +echo " wrk -H 'Host: $server_host' -H 'Accept: $accept' -H 'Connection: keep-alive' --latency -d $duration -c $max_concurrency --timeout 8 -t $max_threads \"${url}2\"" +echo "---------------------------------------------------------" +echo "" +wrk -H "Host: $server_host" -H "Accept: $accept" -H "Connection: keep-alive" --latency -d $duration -c $max_concurrency --timeout 8 -t $max_threads "${url}2" +sleep 5 + +for c in $levels +do +echo "" +echo "---------------------------------------------------------" +echo " Queries: $c for $name" +echo " wrk -H 'Host: $server_host' -H 'Accept: $accept' -H 'Connection: keep-alive' --latency -d $duration -c $max_concurrency --timeout 8 -t $max_threads \"$url$c\"" +echo "---------------------------------------------------------" +echo "" +STARTTIME=$(date +"%s") +wrk -H "Host: $server_host" -H "Accept: $accept" -H "Connection: keep-alive" --latency -d $duration -c $max_concurrency --timeout 8 -t $max_threads "$url$c" +echo "STARTTIME $STARTTIME" +echo "ENDTIME $(date +"%s")" +sleep 2 +done diff --git a/benchmarks/load-testing/wrk/wrk.dockerfile b/benchmarks/load-testing/wrk/wrk.dockerfile new file mode 100644 index 00000000..9bcf42f7 --- /dev/null +++ b/benchmarks/load-testing/wrk/wrk.dockerfile @@ -0,0 +1,21 @@ +FROM ubuntu:24.04 + +# Required scripts for benchmarking +COPY concurrency.sh pipeline.lua pipeline.sh query.sh ./ + +ARG DEBIAN_FRONTEND=noninteractive +RUN apt-get -yqq update >/dev/null && \ + apt-get -yqq install >/dev/null \ + curl \ + wrk && \ + chmod 777 concurrency.sh pipeline.sh query.sh + +# Environment vars required by the wrk scripts with nonsense defaults +ENV accept=accept \ + duration=duration \ + levels=levels \ + max_concurrency=max_concurrency \ + max_threads=max_threads \ + name=name \ + pipeline=pipeline \ + server_host=server_host diff --git a/benchmarks/plaintext/plaintext.py b/benchmarks/plaintext/plaintext.py new file mode 100644 index 00000000..8bd8262b --- /dev/null +++ b/benchmarks/plaintext/plaintext.py @@ -0,0 +1,80 @@ +from benchmarks.test_types.verifications import basic_body_verification, verify_headers +from benchmarks.test_types.abstract_test_type import AbstractTestType + + +class TestType(AbstractTestType): + def __init__(self, config): + self.plaintext_url = "" + kwargs = { + 'name': 'plaintext', + 'requires_db': False, + 'accept_header': self.accept('plaintext'), + 'args': ['plaintext_url'] + } + AbstractTestType.__init__(self, config, **kwargs) + + def verify(self, base_url): + url = base_url + self.plaintext_url + headers, body = self.request_headers_and_body(url) + + _, problems = basic_body_verification(body, url, is_json_check=False) + + # plaintext_url should be at least "/plaintext" + if len(self.plaintext_url) < 10: + problems.append( + ("fail", + "Route for plaintext must be at least 10 characters, found '{}' instead".format(self.plaintext_url), + url)) + + if len(problems) > 0: + return problems + + # Case insensitive + body = body.lower() + expected = b"hello, world!" + extra_bytes = len(body) - len(expected) + + if expected not in body: + return [('fail', "Could not find 'Hello, World!' in response.", + url)] + + if extra_bytes > 0: + problems.append( + ('warn', + ("Server is returning %s more bytes than are required. " + "This may negatively affect benchmark performance." % + extra_bytes), url)) + + problems += verify_headers(self.request_headers_and_body, headers, url, should_be='plaintext') + + if len(problems) == 0: + return [('pass', '', url)] + else: + return problems + + def get_url(self): + return self.plaintext_url + + def get_script_name(self): + return 'pipeline.sh' + + def get_script_variables(self, name, url): + return { + 'max_concurrency': + max(self.config.concurrency_levels), + 'name': + name, + 'duration': + self.config.duration, + 'levels': + " ".join("{}".format(item) + for item in self.config.pipeline_concurrency_levels), + 'server_host': + self.config.server_host, + 'url': + url, + 'pipeline': + 16, + 'accept': + "text/plain,text/html;q=0.9,application/xhtml+xml;q=0.9,application/xml;q=0.8,*/*;q=0.7" + } diff --git a/benchmarks/pre-benchmarks/README.md b/benchmarks/pre-benchmarks/README.md new file mode 100755 index 00000000..0f667c0d --- /dev/null +++ b/benchmarks/pre-benchmarks/README.md @@ -0,0 +1,93 @@ +# Congratulations! + +You have successfully built a new test in the suite! + +There are some remaining tasks to do before you are ready to open a pull request, however. + +## Next Steps + +1. Gather your source code. + +You will need to ensure that your source code is beneath this directory. The most common solution is to include a `src` directory and place your source code there. + +2. Edit `benchmark_config.json` + +You will need alter `benchmark_config.json` to have the appropriate end-points and port specified. + +3. Create `$NAME.dockerfile` + +This is the dockerfile that is built into a docker image and run when a benchmark test is run. Specifically, this file tells the suite how to build and start your test application. + +You can create multiple implementations and they will all conform to `[name in benchmark_config.json].dockerfile`. For example, the `default` implementation in `benchmark_config.json` will be `$NAME.dockerfile`, but if you wanted to make another implementation that did only the database tests for MySQL, you could make `$NAME-mysql.dockerfile` and have an entry in your `benchmark_config.json` for `$NAME-mysql`. + +4. Test your application + + $ bw --mode verify --test $NAME + +This will run the suite in `verify` mode for your test. This means that no benchmarks will be captured and we will test that we can hit your implementation end-points specified by `benchmark_config.json` and that the response is correct. + +Once you are able to successfully run your test through our suite in this way **and** your test passes our validation, you may move on to the next step. + +5. Add your test to `.github/workflows/build.yml` + +Edit `.github/workflows/build.yml` to ensure that Github Actions will automatically run our verification tests against your new test. This file is kept in alphabetical order, so find where `TESTDIR=$LANGUAGE/$NAME` should be inserted under `env > matrix` and put it there. + +6. Fix this `README.md` and open a pull request + +Starting on line 49 is your actual `README.md` that will sit with your test implementation. Update all the dummy values to their correct values so that when people visit your test in our Github repository, they will be greated with information on how your test implementation works and where to look for useful source code. + +After you have the real `README.md` file in place, delete everything above line 59 and you are ready to open a pull request. + +Thanks and Cheers! + + + + + + + +# $DISPLAY_NAME Benchmarking Test + +### Test Type Implementation Source Code + +* [JSON](Relative/Path/To/Your/Source/File) +* [PLAINTEXT](Relative/Path/To/Your/Source/File) +* [DB](Relative/Path/To/Your/Source/File) +* [QUERY](Relative/Path/To/Your/Source/File) +* [CACHED QUERY](Relative/Path/To/Your/Source/File) +* [UPDATE](Relative/Path/To/Your/Source/File) +* [FORTUNES](Relative/Path/To/Your/Source/File) + +## Important Libraries +The tests were run with: +* [Software](https://www.example1.com/) +* [Example](http://www.example2.com/) + +## Test URLs +### JSON + +http://localhost:8080/json + +### PLAINTEXT + +http://localhost:8080/plaintext + +### DB + +http://localhost:8080/db + +### QUERY + +http://localhost:8080/query?queries= + +### CACHED QUERY + +http://localhost:8080/cached_query?queries= + +### UPDATE + +http://localhost:8080/update?queries= + +### FORTUNES + +http://localhost:8080/fortunes diff --git a/benchmarks/pre-benchmarks/benchmark_config.json b/benchmarks/pre-benchmarks/benchmark_config.json new file mode 100755 index 00000000..7eb95788 --- /dev/null +++ b/benchmarks/pre-benchmarks/benchmark_config.json @@ -0,0 +1,26 @@ +{ + "framework": "$NAME", + "tests": [ + { + "default": { + "json_url": "/json", + "plaintext_url": "/plaintext", + "port": 8080, + "approach": "$APPROACH", + "classification": "$CLASSIFICATION", + "database": "$DATABASE", + "framework": "$FRAMEWORK", + "language": "$LANGUAGE", + "flavor": "None", + "orm": "$ORM", + "platform": "$PLATFORM", + "webserver": "$WEBSERVER", + "os": "Linux", + "database_os": "Linux", + "display_name": "$DISPLAY_NAME", + "notes": "", + "versus": "$VERSUS" + } + } + ] +} diff --git a/benchmarks/query/query.py b/benchmarks/query/query.py new file mode 100644 index 00000000..57b2c1de --- /dev/null +++ b/benchmarks/query/query.py @@ -0,0 +1,66 @@ +from benchmarks.test_types.abstract_test_type import AbstractTestType +from benchmarks.test_types.verifications import verify_query_cases + + +class TestType(AbstractTestType): + def __init__(self, config): + self.query_url = "" + kwargs = { + 'name': 'query', + 'accept_header': self.accept('json'), + 'requires_db': True, + 'args': ['query_url', 'database'] + } + AbstractTestType.__init__(self, config, **kwargs) + + def get_url(self): + return self.query_url + + def verify(self, base_url): + ''' + Validates the response is a JSON array of + the proper length, each JSON Object in the array + has keys 'id' and 'randomNumber', and these keys + map to integers. Case insensitive and + quoting style is ignored + ''' + + url = base_url + self.query_url + cases = [('2', 'fail'), ('0', 'fail'), ('foo', 'fail'), + ('501', 'warn'), ('', 'fail')] + + problems = verify_query_cases(self, cases, url, False) + + # queries_url should be at least "/queries/" + # some frameworks use a trailing slash while others use ?q= + if len(self.query_url) < 9: + problems.append( + ("fail", + "Route for queries must be at least 9 characters, found '{}' instead".format(self.query_url), + url)) + + if len(problems) == 0: + return [('pass', '', url + case) for case, _ in cases] + else: + return problems + + def get_script_name(self): + return 'query.sh' + + def get_script_variables(self, name, url): + return { + 'max_concurrency': + max(self.config.concurrency_levels), + 'name': + name, + 'duration': + self.config.duration, + 'levels': + " ".join("{}".format(item) for item in self.config.query_levels), + 'server_host': + self.config.server_host, + 'url': + url, + 'accept': + "application/json,text/html;q=0.9,application/xhtml+xml;q=0.9,application/xml;q=0.8,*/*;q=0.7" + } diff --git a/benchmarks/update/update.py b/benchmarks/update/update.py new file mode 100644 index 00000000..6226035a --- /dev/null +++ b/benchmarks/update/update.py @@ -0,0 +1,65 @@ +from benchmarks.test_types.abstract_test_type import AbstractTestType +from benchmarks.test_types.verifications import verify_query_cases + + +class TestType(AbstractTestType): + def __init__(self, config): + self.update_url = "" + kwargs = { + 'name': 'update', + 'accept_header': self.accept('json'), + 'requires_db': True, + 'args': ['update_url', 'database'] + } + AbstractTestType.__init__(self, config, **kwargs) + + def get_url(self): + return self.update_url + + def verify(self, base_url): + ''' + Validates the response is a JSON array of + the proper length, each JSON Object in the array + has keys 'id' and 'randomNumber', and these keys + map to integers. Case insensitive and + quoting style is ignored + ''' + + url = base_url + self.update_url + cases = [('2', 'fail'), ('0', 'fail'), ('foo', 'fail'), + ('501', 'warn'), ('', 'fail')] + problems = verify_query_cases(self, cases, url, True) + + # update_url should be at least "/update/" + # some frameworks use a trailing slash while others use ?q= + if len(self.update_url) < 8: + problems.append( + ("fail", + "Route for update must be at least 8 characters, found '{}' instead".format(self.update_url), + url)) + + if len(problems) == 0: + return [('pass', '', url + case) for (case, _) in cases] + else: + return problems + + def get_script_name(self): + return 'query.sh' + + def get_script_variables(self, name, url): + return { + 'max_concurrency': + max(self.config.concurrency_levels), + 'name': + name, + 'duration': + self.config.duration, + 'levels': + " ".join("{}".format(item) for item in self.config.query_levels), + 'server_host': + self.config.server_host, + 'url': + url, + 'accept': + "application/json,text/html;q=0.9,application/xhtml+xml;q=0.9,application/xml;q=0.8,*/*;q=0.7" + } diff --git a/benchmarks/verifications.py b/benchmarks/verifications.py new file mode 100644 index 00000000..e49be7f2 --- /dev/null +++ b/benchmarks/verifications.py @@ -0,0 +1,474 @@ +import json +import re +import traceback +import multiprocessing + +from datetime import datetime +from utils.output_helper import log +from infrastructure.docker.databases import databases +from time import sleep + +# Cross-platform colored text +from colorama import Fore, Style + + +def basic_body_verification(body, url, is_json_check=True): + ''' + Takes in a raw (stringy) response body, checks that it is non-empty, + and that it is valid JSON (i.e. can be deserialized into a dict/list of dicts) + Returns the deserialized body as a dict (or list of dicts), and also returns any + problems encountered, always as a list. If len(problems) > 0, + then the response body does not have to be examined further and the caller + should handle the failing problem(s). + Plaintext and Fortunes set `is_json_check` to False + ''' + + # Empty Response? + if body is None: + return None, [('fail', 'No response', url)] + elif len(body) == 0: + return None, [('fail', 'Empty response', url)] + + # Valid JSON? + if is_json_check: + try: + response = json.loads(body) + return response, [] + except ValueError as ve: + return None, [('fail', 'Invalid JSON: %s' % ve, url)] + + # Fortunes and Plaintext only use this for the empty response tests + # they do not need or expect a dict back + return None, [] + + +def verify_headers(request_headers_and_body, headers, url, should_be='json'): + ''' + Verifies the headers of a framework response + param `should_be` is a switch for the three acceptable content types + ''' + + problems = [] + + for v in (v for v in ('Server', 'Date', 'Content-Type') + if v.lower() not in headers): + problems.append(('fail', 'Required response header missing: %s' % v, + url)) + + if all(v.lower() not in headers + for v in ('Content-Length', 'Transfer-Encoding')): + problems.append(( + 'fail', + 'Required response size header missing, please include either "Content-Length" or "Transfer-Encoding"', + url)) + + date = headers.get('Date') + if date is not None: + expected_date_format = '%a, %d %b %Y %H:%M:%S %Z' + try: + datetime.strptime(date, expected_date_format) + except ValueError: + problems.append(( + 'warn', + 'Invalid Date header, found \"%s\", did not match \"%s\".' + % (date, expected_date_format), url)) + + # Verify response content + # Make sure that the date object isn't cached + sleep(3) + second_headers, body2 = request_headers_and_body(url) + second_date = second_headers.get('Date') + + date2 = second_headers.get('Date') + if date == date2: + problems.append(( + 'fail', + 'Invalid Cached Date. Found \"%s\" and \"%s\" on separate requests.' + % (date, date2), url)) + + content_type = headers.get('Content-Type') + if content_type is not None: + types = { + 'json': '^application/json(; ?charset=(UTF|utf)-8)?$', + 'html': '^text/html; ?charset=(UTF|utf)-8$', + 'plaintext': '^text/plain(; ?charset=(UTF|utf)-8)?$' + } + expected_type = types[should_be] + + if not re.match(expected_type, content_type): + problems.append(( + 'fail', + 'Invalid Content-Type header, found \"%s\", did not match \"%s\".' + % (content_type, expected_type), url)) + + return problems + + +def verify_helloworld_object(json_object, url): + ''' + Ensure that the JSON object closely resembles + { 'message': 'Hello, World!' } + ''' + + problems = [] + + try: + # Make everything case insensitive + json_object = {k.lower(): v.lower() for k, v in json_object.items()} + except: + return [('fail', "Not a valid JSON object", url)] + + if 'message' not in json_object: + return [('fail', "Missing required key 'message'", url)] + else: + json_len = len(json_object) + if json_len > 1: + additional = ', '.join( + [k for k in json_object.keys() if k != 'message']) + problems.append( + ('warn', "Too many JSON key/value pairs, consider removing: %s" + % additional, url)) + if json_len > 27: + problems.append( + 'warn', + "%s additional response byte(s) found. Consider removing unnecessary whitespace." + % (json_len - 26)) + message = json_object['message'] + + if message != 'hello, world!': + return [('fail', + "Expected message of 'hello, world!', got '%s'" % message, + url)] + return problems + + +def verify_randomnumber_object(db_object, url, max_infraction='fail'): + ''' + Ensures that `db_object` is a JSON object with + keys 'id' and 'randomNumber' that both map to ints. + Should closely resemble: + { "id": 2354, "randomNumber": 8952 } + ''' + + problems = [] + + # Dict is expected + # Produce error for bytes in non-cases + if type(db_object) is not dict: + got = str(db_object)[:20] + if len(str(db_object)) > 20: + got = str(db_object)[:17] + '...' + return [(max_infraction, + "Expected a JSON object, got '%s' instead" % got, url)] + + # Make keys case insensitive + db_object = {k.lower(): v for k, v in db_object.items()} + required_keys = set(['id', 'randomnumber']) + + for v in (v for v in required_keys if v not in db_object): + problems.append( + (max_infraction, + 'Response object was missing required key: %s' % v, url)) + + if len(db_object) > len(required_keys): + extras = set(db_object.keys()) - required_keys + problems.append( + ('warn', 'An extra key(s) is being included with the db object: %s' + % ', '.join(extras), url)) + + # All required keys must be present + if len(problems) > 0: + return problems + + # Assert key types and values + try: + o_id = int(db_object['id']) + + if o_id > 10000 or o_id < 1: + problems.append(( + 'warn', + 'Response key id should be between 1 and 10,000: ' + str(o_id), + url)) + except TypeError as e: + problems.append( + (max_infraction, + "Response key 'id' does not map to an integer - %s" % e, url)) + + try: + o_rn = int(db_object['randomnumber']) + + if o_rn > 10000: + problems.append(( + 'warn', + 'Response key `randomNumber` is over 10,000. This may negatively affect performance by sending extra bytes', + url)) + except TypeError as e: + problems.append( + (max_infraction, + "Response key 'randomnumber' does not map to an integer - %s" % e, + url)) + + return problems + + +def verify_randomnumber_list(expected_len, + headers, + body, + url, + max_infraction='fail'): + ''' + Validates that the object is a list containing a number of + randomnumber object. Should closely resemble: + [{ "id": 2354, "randomNumber": 8952 }, { "id": 4421, "randomNumber": 32 }, ... ] + ''' + + response, problems = basic_body_verification(body, url) + + if len(problems) > 0: + return problems + + # This path will be hit when the framework returns a single JSON object + # rather than a list containing one element. + if type(response) is not list: + problems.append((max_infraction, + 'Top-level JSON is an object, not an array', + url)) + problems += verify_randomnumber_object(response, url, max_infraction) + return problems + + if any(type(item) is not dict for item in response): + problems.append( + (max_infraction, + 'Not all items in the JSON array were JSON objects', url)) + + if len(response) != expected_len: + problems.append((max_infraction, + "JSON array length of %s != expected length of %s" % + (len(response), expected_len), url)) + + # Verify individual objects, arbitrarily stop after 5 bad ones are found + # i.e. to not look at all 500 + badObjectsFound = 0 + inner_objects = iter(response) + + try: + while badObjectsFound < 5: + obj = next(inner_objects) + findings = verify_randomnumber_object(obj, url, max_infraction) + + if len(findings) > 0: + problems += findings + badObjectsFound += 1 + except StopIteration: + pass + + return problems + + +def verify_updates(old_worlds, new_worlds, updates_expected, url): + ''' + Validates that the /updates requests actually updated values in the database and didn't + just return a JSON list of the correct number of World items. + + old_worlds a JSON object containing the state of the Worlds table BEFORE the /updates requests + new_worlds a JSON object containing the state of the Worlds table AFTER the /updates requests + If no items were updated, this validation test returns a "fail." + + If only some items were updated (within a 5% margin of error), this test returns a "warn". + This is to account for the unlikely, but possible situation where an entry in the World + table is updated to the same value it was previously set as. + ''' + successful_updates = 0 + problems = [] + + n = 0 + while n < len(old_worlds) and successful_updates == 0: + for i in range(1, 10001): + try: + entry_id = str(i) + if entry_id in old_worlds[n] and entry_id in new_worlds[n]: + if old_worlds[n][entry_id] != new_worlds[n][entry_id]: + successful_updates += 1 + except Exception: + tb = traceback.format_exc() + log(tb) + n += 1 + + if successful_updates == 0: + problems.append(("fail", "No items were updated in the database.", + url)) + elif successful_updates <= (updates_expected * 0.90): + problems.append(( + "fail", + "Only %s items were updated in the database out of roughly %s expected." + % (successful_updates, updates_expected), url)) + elif successful_updates <= (updates_expected * 0.95): + problems.append(( + "warn", + "There may have been an error updating the database. Only %s items were updated in the database out of the roughly %s expected." + % (successful_updates, updates_expected), url)) + + return problems + + +def verify_query_cases(self, cases, url, check_updates=False): + ''' + The /updates and /queries tests accept a `queries` parameter + that is expected to be between 1-500. + This method execises a framework with different `queries` parameter values + then verifies that the framework responds appropriately. + The `cases` parameter should be a list of 2-tuples containing the query case + and the consequence level should the cases fail its verifications, e.g.: + cases = [ + ('2', 'fail'), + ('0', 'fail'), + ('foo', 'fail'), + ('501', 'warn'), + ('', 'fail') + ] + The reason for using 'warn' is generally for a case that will be allowed in the + current run but that may/will be a failing case in future rounds. The cases above + suggest that not sanitizing the `queries` parameter against non-int input, or failing + to ensure the parameter is between 1-500 will just be a warn, + and not prevent the framework from being benchmarked. + ''' + problems = [] + MAX = 500 + MIN = 1 + # Initialization for query counting + repetitions = 1 + concurrency = max(self.config.concurrency_levels) + expected_queries = 20 * repetitions * concurrency + expected_rows = expected_queries + + # Only load in the World table if we are doing an Update verification + world_db_before = {} + if check_updates: + world_db_before = databases[self.database.lower()].get_current_world_table(self.config) + expected_queries = expected_queries + concurrency * repetitions # eventually bulk updates! + + for q, max_infraction in cases: + case_url = url + q + headers, body = self.request_headers_and_body(case_url) + + try: + queries = int(q) # drops down for 'foo' and '' + + if queries > MAX: + expected_len = MAX + elif queries < MIN: + expected_len = MIN + else: + expected_len = queries + + problems += verify_randomnumber_list(expected_len, headers, body, + case_url, max_infraction) + problems += verify_headers(self.request_headers_and_body, headers, case_url) + + # Only check update changes if we are doing an Update verification and if we're testing + # the highest number of queries, to ensure that we don't accidentally FAIL for a query + # that only updates 1 item and happens to set its randomNumber to the same value it + # previously held + if check_updates and queries >= MAX: + world_db_after = databases[self.database.lower()].get_current_world_table(self.config) + problems += verify_updates(world_db_before, world_db_after, + MAX, case_url) + + except ValueError: + warning = ( + '%s given for stringy `queries` parameter %s\n' + 'Suggestion: modify your /queries route to handle this case ' + '(this will be a failure in future rounds, please fix)') + + if body is None: + problems.append((max_infraction, warning % ('No response', q), + case_url)) + elif len(body) == 0: + problems.append((max_infraction, warning % ('Empty response', + q), case_url)) + else: + expected_len = 1 + # Strictness will be upped in a future round, i.e. Frameworks currently do not have + # to gracefully handle absent, or non-intlike `queries` + # parameter input + problems += verify_randomnumber_list( + expected_len, headers, body, case_url, max_infraction) + problems += verify_headers(self.request_headers_and_body, headers, case_url) + + if hasattr(self, 'database'): + # verify the number of queries and rows read for 20 queries, with a concurrency level of 512, with 2 repetitions + problems += verify_queries_count(self, "world", url + "20", concurrency, repetitions, expected_queries, + expected_rows, check_updates) + return problems + + +def verify_queries_count(self, tbl_name, url, concurrency=512, count=2, expected_queries=1024, expected_rows=1024, + check_updates=False): + ''' + Checks that the number of executed queries, at the given concurrency level, + corresponds to: the total number of http requests made * the number of queries per request. + No margin is accepted on the number of queries, which seems reliable. + On the number of rows read or updated, the margin related to the database applies (1% by default see cls.margin) + On updates, if the use of bulk updates is detected (number of requests close to that expected), a margin + (5% see bulk_margin) is allowed on the number of updated rows. + ''' + log("VERIFYING QUERY COUNT FOR %s" % url, border='-', color=Fore.WHITE + Style.BRIGHT) + + problems = [] + + queries, rows, rows_updated, margin, trans_failures = databases[self.database.lower()].verify_queries(self.config, + tbl_name, url, + concurrency, + count, + check_updates) + + isBulk = check_updates and (queries < 1.001 * expected_queries) and (queries > 0.999 * expected_queries) + + if check_updates and not isBulk: # Restore the normal queries number if bulk queries are not used + expected_queries = (expected_queries - count * concurrency) * 2 + + # Add a margin based on the number of cpu cores + queries_margin = 1.015 # For a run on Travis + if multiprocessing.cpu_count() > 2: + queries_margin = 1 # real run (Citrine or Azure) -> no margin on queries + # Check for transactions failures (socket errors...) + if trans_failures > 0: + problems.append(( + "fail", + "%s failed transactions." + % trans_failures, url)) + + problems.append( + display_queries_count_result(queries * queries_margin, expected_queries, queries, "executed queries", url)) + + problems.append(display_queries_count_result(rows, expected_rows, int(rows / margin), "rows read", url)) + + if check_updates: + bulk_margin = 1 + if isBulk: # Special marge for bulk queries + bulk_margin = 1.05 + problems.append( + display_queries_count_result(rows_updated * bulk_margin, expected_rows, int(rows_updated / margin), + "rows updated", url)) + + return problems + + +def display_queries_count_result(result, expected_result, displayed_result, caption, url): + ''' + Returns a single result in counting queries, rows read or updated. + result corresponds to the effective result adjusted by the margin. + displayed_result is the effective result (without correction). + ''' + if result > expected_result * 1.05: + return ( + "warn", + "%s %s in the database instead of %s expected. This number is excessively high." + % (displayed_result, caption, expected_result), url) + elif result < expected_result: + return ( + "fail", + "Only %s %s in the database out of roughly %s expected." + % (displayed_result, caption, expected_result), url) + else: + return ("pass", "%s: %s/%s" % (caption.capitalize(), displayed_result, expected_result), url) diff --git a/frameworks/Go/goravel/src/fiber/go.mod b/frameworks/Go/goravel/src/fiber/go.mod index b31a4b9b..87ba6260 100644 --- a/frameworks/Go/goravel/src/fiber/go.mod +++ b/frameworks/Go/goravel/src/fiber/go.mod @@ -65,7 +65,7 @@ require ( github.com/gofiber/template v1.8.3 // indirect github.com/gofiber/template/html/v2 v2.1.1 // indirect github.com/gofiber/utils v1.1.0 // indirect - github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.1 // indirect github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/golang-migrate/migrate/v4 v4.17.1 // indirect github.com/golang-module/carbon/v2 v2.3.12 // indirect diff --git a/frameworks/Go/goravel/src/fiber/go.sum b/frameworks/Go/goravel/src/fiber/go.sum index e036eea0..2106df44 100644 --- a/frameworks/Go/goravel/src/fiber/go.sum +++ b/frameworks/Go/goravel/src/fiber/go.sum @@ -285,8 +285,9 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= +github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= diff --git a/frameworks/Java/ninja-standalone/pom.xml b/frameworks/Java/ninja-standalone/pom.xml index 570ed726..1477d2dc 100644 --- a/frameworks/Java/ninja-standalone/pom.xml +++ b/frameworks/Java/ninja-standalone/pom.xml @@ -20,7 +20,7 @@ 2.2.2205.4.24.Final - 6.0.20.Final + 6.2.0.Final2.3.09.4.18.v201904298.0.28 diff --git a/frameworks/Java/restexpress/pom.xml b/frameworks/Java/restexpress/pom.xml index 743f4cf5..982892dd 100644 --- a/frameworks/Java/restexpress/pom.xml +++ b/frameworks/Java/restexpress/pom.xml @@ -37,7 +37,7 @@ com.fasterxml.jackson.core jackson-databind - 2.12.6.1 + 2.12.7.1 com.fasterxml.jackson.core
" and "A computer scientist is someone who fixes things that aren't broken.