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 12, 2023
1 parent 72bfd1a commit 5ca6fed
Show file tree
Hide file tree
Showing 22 changed files with 268 additions and 45 deletions.
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
44 changes: 42 additions & 2 deletions web/client/codechecker_client/cmd/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
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 +416,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 @@ -576,6 +581,31 @@ 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 stats.num_of_reports > p.reportLimit:
over_limit = stats.num_of_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.
Please review the 'Checker Statistics' section of this output to gain a better
understanding of which checkers have generated an excessive number of reports.
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.
The relevant part of the above output can be reproduced with the:
`CodeChecker parse --summary <report-dir>` command.
* Configured report limit for this product: {p.reportLimit}
* Number of reports in the result folder: {stats.num_of_reports}
* The report folder exceeds the limit by {over_limit} reports
""")
sys.exit(1)

zip_size = os.stat(zip_file).st_size

LOG.info("Compressing report zip file...")
Expand Down Expand Up @@ -761,6 +791,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 +806,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
55 changes: 55 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 @@ -1069,6 +1070,57 @@ def __validate_and_add_report_annotations(
f"'{ALLOWED_ANNOTATIONS[key]['display']}'."
)

def __check_report_count(self, report_dir: Path):
"""
This method checks the number of reports in the given report,
Raises exception if the number of reports is more than the
that is configured for the product.
"""
# Read all plist files in report directory.
plist_files = list(report_dir.glob('**/*.plist'))
report_count = 0
for plist_file in plist_files:
# The check_name tag only occurs exactly once per report.
# Don't parse plist, just count the number of
# check_name occurances.
unique_tag = b'check_name'
with plist_file.open('rb') as f:
plist_file_content = f.read()
report_count += plist_file_content.count(unique_tag)

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

if report_count > report_limit:
LOG.error("The number of reports in the given zip file is " +
"larger than the allowed." +
f"The limit: {report_limit}, reports: {report_count}!")
extra_info = [
"report_limit",
f"limit:{report_limit}",
f"report_count:{report_count}"
]
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. Please review the 'Checker " +
"Statistics' section of this output to gain a better " +
"understanding of which checkers have generated an " +
"excessive number of reports. 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: {report_limit}. Current Reports: {report_count} " +
f"Excess Reports: {report_count-report_limit}",
extra_info)

def __store_reports(
self,
session: DBSession,
Expand Down Expand Up @@ -1264,6 +1316,9 @@ def store(self) -> int:
content_hash_file = os.path.join(
zip_dir, 'content_hashes.json')

# Validate that the report count is less than it is allowed.
self.__check_report_count(Path(report_dir))

filename_to_hash = load_json(content_hash_file, {})

with LogTask(run_name=self.__name,
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
5 changes: 4 additions & 1 deletion web/server/codechecker_server/database/config_db_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class Product(Base):
display_name = Column(String, nullable=False)
description = Column(Text)
run_limit = Column(Integer)
report_limit = Column(Integer, nullable=False, server_default="500000")
num_of_runs = Column(Integer, server_default="0")
latest_storage_date = Column(DateTime, nullable=True)

Expand All @@ -49,13 +50,15 @@ class Product(Base):
confidentiality = Column(String, nullable=True)

def __init__(self, endpoint, conn_str, name=None, description=None,
run_limit=None, is_review_status_change_disabled=False,
run_limit=None, report_limit=500000,
is_review_status_change_disabled=False,
confidentiality=None):
self.endpoint = endpoint
self.connection = conn_str
self.display_name = name if name else endpoint
self.description = description
self.run_limit = run_limit
self.report_limit = report_limit
self.is_review_status_change_disabled = \
True if is_review_status_change_disabled else False
self.confidentiality = confidentiality
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Store limit
Revision ID: 00099e8bc212
Revises: 7829789fc19c
Create Date: 2023-03-10 16:45:19.301602
"""

# revision identifiers, used by Alembic.
revision = '00099e8bc212'
down_revision = '7829789fc19c'
branch_labels = None
depends_on = None

from alembic import op
import sqlalchemy as sa


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('products', sa.Column('report_limit', sa.Integer(), server_default='500000', nullable=False))
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('products', 'report_limit')
# ### end Alembic commands ###
Loading

0 comments on commit 5ca6fed

Please sign in to comment.