Skip to content

Commit

Permalink
[server] Rate limit based on report count
Browse files Browse the repository at this point in the history
This patch implements a basic implementation of rate limiting based on
the raw report count in the incoming storage request.

[feat][server][gui] Report limit in prof config

Added database entries in config db.
Modified api for passing report limit information.
Added widgets on the gui.
Added config option for commandline project creation.
A test is included.

Resolve conflict

Fix product remove parameter in web tests
  • Loading branch information
vodorok committed Oct 18, 2023
1 parent 72bfd1a commit 5df6b98
Show file tree
Hide file tree
Showing 23 changed files with 258 additions and 46 deletions.
4 changes: 4 additions & 0 deletions docs/web/products.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ making a new product available on the server.
```
usage: CodeChecker cmd products add [-h] [-n DISPLAY_NAME]
[--description DESCRIPTION]
[--report-limit REPORT_LIMIT]
[--sqlite SQLITE_FILE | --postgresql]
[--dbaddress DBADDRESS] [--dbport DBPORT]
[--dbusername DBUSERNAME]
Expand All @@ -142,6 +143,9 @@ optional arguments:
--description DESCRIPTION
A custom textual description to be shown alongside the
product.
--report-limit REPORT_LIMIT
The maximum number of reports allowed to store in one
run, if exceeded, the storeaction will be rejected.
database arguments:
NOTE: These database arguments are relative to the server machine, as it
Expand Down
Binary file not shown.
2 changes: 1 addition & 1 deletion web/api/js/codechecker-api-node/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codechecker-api",
"version": "6.53.0",
"version": "6.54.0",
"description": "Generated node.js compatible API stubs for CodeChecker server.",
"main": "lib",
"homepage": "https://github.com/Ericsson/codechecker",
Expand Down
6 changes: 4 additions & 2 deletions web/api/products.thrift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ struct ProductConfiguration {
5: optional DatabaseConnection connection,
6: i64 runLimit,
7: optional bool isReviewStatusChangeDisabled,
8: optional Confidentiality confidentiality
8: optional Confidentiality confidentiality,
9: optional i64 reportLimit
}
typedef list<ProductConfiguration> ProductConfigurations

Expand All @@ -53,7 +54,8 @@ struct Product {
11: i64 runLimit, // Number of allowed runs for this product.
12: list<string> admins, // Administrators of this product.
13: list<string> runStoreInProgress, // List of run names which are in progress.
14: optional Confidentiality confidentiality // Confidentiality classification of the product
14: optional Confidentiality confidentiality, // Confidentiality classification of the product.
15: optional i64 reportLimit // Maximum number of reports allowed in a run.
}
typedef list<Product> Products

Expand Down
Binary file modified web/api/py/codechecker_api/dist/codechecker_api.tar.gz
Binary file not shown.
2 changes: 1 addition & 1 deletion web/api/py/codechecker_api/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
with open('README.md', encoding='utf-8', errors="ignore") as f:
long_description = f.read()

api_version = '6.53.0'
api_version = '6.54.0'

setup(
name='codechecker_api',
Expand Down
Binary file not shown.
2 changes: 1 addition & 1 deletion web/api/py/codechecker_api_shared/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
with open('README.md', encoding='utf-8', errors="ignore") as f:
long_description = f.read()

api_version = '6.53.0'
api_version = '6.54.0'

setup(
name='codechecker_api_shared',
Expand Down
9 changes: 9 additions & 0 deletions web/client/codechecker_client/cmd/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,15 @@ def __register_add(parser):
help="A custom textual description to be shown "
"alongside the product.")

parser.add_argument('--report-limit',
type=int,
dest="report_limit",
default=argparse.SUPPRESS,
required=False,
help="The maximum number of reports allowed to "
"store in one run, if exceeded, the store "
"action will be rejected.")

dbmodes = parser.add_argument_group(
"database arguments",
"NOTE: These database arguments are relative to the server "
Expand Down
45 changes: 42 additions & 3 deletions web/client/codechecker_client/cmd/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,13 @@
from codechecker_report_converter import twodim
from codechecker_report_converter.report import Report, report_file, \
reports as reports_helper, statistics as report_statistics
from codechecker_report_converter.report.hash import HashType
from codechecker_report_converter.report.hash import HashType, \
get_report_path_hash
from codechecker_report_converter.source_code_comment_handler import \
SourceCodeCommentHandler

from codechecker_client import client as libclient
from codechecker_client import product
from codechecker_common import arg, logger, cmd_config
from codechecker_common.checker_labels import CheckerLabels
from codechecker_common.util import load_json
Expand Down Expand Up @@ -415,7 +417,11 @@ def parse_analyzer_result_files(
return analyzer_result_file_reports


def assemble_zip(inputs, zip_file, client, checker_labels: CheckerLabels):
def assemble_zip(inputs,
zip_file,
client,
prod_client,
checker_labels: CheckerLabels):
"""Collect and compress report and source files, together with files
contanining analysis related information into a zip file which
will be sent to the server.
Expand Down Expand Up @@ -459,6 +465,7 @@ def assemble_zip(inputs, zip_file, client, checker_labels: CheckerLabels):
changed_files = set()
file_paths = set()
file_report_positions: FileReportPositions = defaultdict(set)
unique_reports = set()
for file_path, reports in analyzer_result_file_reports.items():
files_to_compress.add(file_path)
stats.num_of_analyzer_result_files += 1
Expand All @@ -467,6 +474,9 @@ def assemble_zip(inputs, zip_file, client, checker_labels: CheckerLabels):
if report.changed_files:
changed_files.update(report.changed_files)
continue
# Need to calculate unique reoirt count to determine report limit
unique_reports.add(get_report_path_hash(report))

stats.add_report(report)

file_paths.update(report.original_files)
Expand Down Expand Up @@ -576,6 +586,25 @@ def assemble_zip(inputs, zip_file, client, checker_labels: CheckerLabels):
# Print statistics what will be stored to the server.
stats.write()

# Fail store early if too many reports.
p = prod_client.getCurrentProduct()
if len(unique_reports) > p.reportLimit:
LOG.error(f"""Report Limit Exceeded
This report folder cannot be stored because the number of reports in the
result folder is too high. Usually noisy checkers, generating a lot of
reports are not useful and it is better to disable them.
Run `CodeChecker parse <report_folder>` to gain a better understanding on
report counts.
Disable checkers that have generated an excessive number of reports and then
rerun the analysis to be able to store the results on the server.
Configured report limit for this product: {p.reportLimit}
""")
sys.exit(1)

zip_size = os.stat(zip_file).st_size

LOG.info("Compressing report zip file...")
Expand Down Expand Up @@ -761,6 +790,12 @@ def main(args):

# Setup connection to the remote server.
client = libclient.setup_client(args.product_url)
protocol, host, port, product_name = \
product.split_product_url(args.product_url)
prod_client = libclient.setup_product_client(protocol,
host,
port,
product_name=product_name)

zip_file_handle, zip_file = tempfile.mkstemp('.zip')
LOG.debug("Will write mass store ZIP to '%s'...", zip_file)
Expand All @@ -770,7 +805,11 @@ def main(args):

LOG.debug("Assembling zip file.")
try:
assemble_zip(args.input, zip_file, client, context.checker_labels)
assemble_zip(args.input,
zip_file,
client,
prod_client,
context.checker_labels)
except Exception as ex:
print(ex)
import traceback
Expand Down
5 changes: 5 additions & 0 deletions web/client/codechecker_client/product_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,15 @@ def handle_add_product(args):
desc = convert.to_b64(args.description) \
if 'description' in args else None

report_limit = None
if hasattr(args, "report_limit") and args.report_limit:
report_limit = int(args.report_limit)

prod = ProductConfiguration(
endpoint=args.endpoint,
displayedName_b64=name,
description_b64=desc,
reportLimit=report_limit,
connection=dbc)

LOG.debug("Sending request to add product...")
Expand Down
48 changes: 29 additions & 19 deletions web/client/codechecker_client/thrift_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,26 +56,36 @@ def wrapper(self, *args, **kwargs):
except codechecker_api_shared.ttypes.RequestFailed as reqfailure:
LOG.error('Calling API endpoint: %s', funcName)
if reqfailure.errorCode ==\
codechecker_api_shared.ttypes.ErrorCode.DATABASE:
LOG.error('Database error on server\n%s',
str(reqfailure.message))
elif reqfailure.errorCode ==\
codechecker_api_shared.ttypes.ErrorCode.AUTH_DENIED:
LOG.error('Authentication denied\n %s',
str(reqfailure.message))
elif reqfailure.errorCode ==\
codechecker_api_shared.ttypes.ErrorCode.UNAUTHORIZED:
LOG.error('Unauthorized to access\n %s',
str(reqfailure.message))
LOG.error('Ask the product admin for additional access '
'rights.')
elif reqfailure.errorCode ==\
codechecker_api_shared.ttypes.ErrorCode.API_MISMATCH:
LOG.error('Client/server API mismatch\n %s',
str(reqfailure.message))
codechecker_api_shared.ttypes.ErrorCode.GENERAL and \
reqfailure.extraInfo and \
reqfailure.extraInfo[0] == "report_limit":
# We handle this error in near the business logic.
raise reqfailure
else:
LOG.error('API call error: %s\n%s', funcName, str(reqfailure))
sys.exit(1)
if reqfailure.errorCode ==\
codechecker_api_shared.ttypes.ErrorCode.DATABASE:
LOG.error('Database error on server\n%s',
str(reqfailure.message))
elif reqfailure.errorCode ==\
codechecker_api_shared.ttypes.ErrorCode.AUTH_DENIED:
LOG.error('Authentication denied\n %s',
str(reqfailure.message))
elif reqfailure.errorCode ==\
codechecker_api_shared.ttypes.ErrorCode.UNAUTHORIZED:
LOG.error('Unauthorized to access\n %s',
str(reqfailure.message))
LOG.error('Ask the product admin for additional access '
'rights.')
elif reqfailure.errorCode ==\
codechecker_api_shared.ttypes.ErrorCode.API_MISMATCH:
LOG.error('Client/server API mismatch\n %s',
str(reqfailure.message))
else:
LOG.error('API call error: %s\n%s',
funcName,
str(reqfailure)
)
sys.exit(1)
except TApplicationException as ex:
LOG.error("Internal server error: %s", str(ex.message))
sys.exit(1)
Expand Down
2 changes: 1 addition & 1 deletion web/codechecker_web/shared/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
# The newest supported minor version (value) for each supported major version
# (key) in this particular build.
SUPPORTED_VERSIONS = {
6: 53
6: 54
}

# Used by the client to automatically identify the latest major and minor
Expand Down
41 changes: 41 additions & 0 deletions web/server/codechecker_server/api/mass_store_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from collections import defaultdict
from datetime import datetime
from hashlib import sha256
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any, Dict, List, Optional, Set, Tuple

Expand Down Expand Up @@ -266,13 +267,16 @@ def __init__(
self.__analysis_info: Dict[str, AnalysisInfo] = {}
self.__duration: int = 0
self.__report_count: int = 0
self.__report_limit: int = 0
self.__wrong_src_code_comments: List[str] = []
self.__already_added_report_hashes: Set[str] = set()
self.__severity_map: Dict[str, int] = {}
self.__new_report_hashes: Dict[str, Tuple] = {}
self.__all_report_checkers: Set[str] = set()
self.__added_reports: List[Tuple[DBReport, Report]] = list()

self.__get_report_limit_for_product()

@property
def __manager(self):
return self.__report_server._manager
Expand Down Expand Up @@ -1011,6 +1015,7 @@ def get_missing_file_ids(report: Report) -> List[str]:
else:
fixed_at = run_history_time

self.__check_report_count()
report_id = self.__add_report(
session, run_id, report, file_path_to_id,
rs_from_source, detection_status, detected_at,
Expand Down Expand Up @@ -1069,6 +1074,42 @@ def __validate_and_add_report_annotations(
f"'{ALLOWED_ANNOTATIONS[key]['display']}'."
)

def __get_report_limit_for_product(self):
with DBSession(self.__config_database) as session:
product = session.query(Product).get(self.__product.id)
if product.report_limit:
self.__report_limit = product.report_limit


def __check_report_count(self):
"""
This method comparest the already added report count to the report
limit, Raises exception if the number of reports is more than the
that is configured for the product.
"""
if len(self.__added_reports) >= self.__report_limit:
LOG.error("The number of reports in the given report folder is " +
"larger than the allowed." +
f"The limit: {self.__report_limit}!")
extra_info = [
"report_limit",
f"limit:{self.__report_limit}"
]
raise codechecker_api_shared.ttypes.RequestFailed(
codechecker_api_shared.ttypes.
ErrorCode.GENERAL,
"**Report Limit Exceeded** " +
"This report folder cannot be stored because the number of " +
"reports in the result folder is too high. Usually noisy " +
"checkers, generating a lot of reports are not useful and " +
"it is better to disable them. Run `CodeChecker parse " +
"<report_folder>` to gain a better understanding on report " +
"counts. Disable checkers that have generated an excessive " +
"number of reports and then rerun the analysis to be able " +
"to store the results on the server. " +
f"Limit: {self.__report_limit}",
extra_info)

def __store_reports(
self,
session: DBSession,
Expand Down
8 changes: 7 additions & 1 deletion web/server/codechecker_server/api/product_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ def __get_product(self, session, product):
confidentiality = \
confidentiality_enum(product.confidentiality)

report_limit = product.report_limit

return server_product, ttypes.Product(
id=product.id,
endpoint=product.endpoint,
Expand All @@ -141,7 +143,8 @@ def __get_product(self, session, product):
administrating=self.__administrating(args),
databaseStatus=server_product.db_status,
admins=[admin.name for admin in admins],
confidentiality=confidentiality)
confidentiality=confidentiality,
reportLimit=report_limit)

@timeit
def getPackageVersion(self):
Expand Down Expand Up @@ -295,6 +298,7 @@ def getProductConfiguration(self, product_id):
description_b64=descr,
connection=dbc,
runLimit=product.run_limit,
reportLimit=product.report_limit,
isReviewStatusChangeDisabled=is_review_status_change_disabled,
confidentiality=confidentiality)

Expand Down Expand Up @@ -387,6 +391,7 @@ def addProduct(self, product):
name=displayed_name,
description=description,
run_limit=product.runLimit,
report_limit=product.reportLimit,
is_review_status_change_disabled=is_rws_change_disabled,
confidentiality=confidentiality)

Expand Down Expand Up @@ -583,6 +588,7 @@ def editProduct(self, product_id, new_config):
# Update the settings in the database.
product.endpoint = new_config.endpoint
product.run_limit = new_config.runLimit
product.report_limit = new_config.reportLimit
product.is_review_status_change_disabled = \
new_config.isReviewStatusChangeDisabled
product.connection = conn_str
Expand Down
Loading

0 comments on commit 5df6b98

Please sign in to comment.