From: Stefan Eissing Date: Wed, 17 Jul 2024 10:26:40 +0000 (+0200) Subject: pytests: scorecard upload tests X-Git-Tag: curl-8_9_0~32 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=35ed3f7a89379c948506d9ea0d9ccc736d5761fa;p=thirdparty%2Fcurl.git pytests: scorecard upload tests - add upload tests to scorecard, invoke with > python3 tests/http/scorecard.py -u h1|h2|h3 - add a reverse proxy setup from Caddy to httpd for upload tests since Caddy does not have other PUT/POST handling - add caddy tests in test_08 for POST/PUT - increase read buffer in mod_curltest for larger reads Closes #14208 --- diff --git a/tests/http/scorecard.py b/tests/http/scorecard.py index 8b61517920..4e4c2c8cb1 100644 --- a/tests/http/scorecard.py +++ b/tests/http/scorecard.py @@ -106,7 +106,7 @@ class ScoreCard: while flen < fsize: fd.write(data1k) flen += len(data1k) - return flen + return fpath def _check_downloads(self, r: ExecResult, count: int): error = '' @@ -261,6 +261,163 @@ class ScoreCard: scores[via][label] = results return scores + def _check_uploads(self, r: ExecResult, count: int): + error = '' + if r.exit_code != 0: + error += f'exit={r.exit_code} ' + if r.exit_code != 0 or len(r.stats) != count: + error += f'stats={len(r.stats)}/{count} ' + fails = [s for s in r.stats if s['response_code'] != 200] + if len(fails) > 0: + error += f'{len(fails)} failed' + for f in fails: + error += f'[{f["response_code"]}]' + return error if len(error) > 0 else None + + def upload_single(self, url: str, proto: str, fpath: str, count: int): + sample_size = count + count = 1 + samples = [] + errors = [] + profiles = [] + self.info(f'single...') + for i in range(sample_size): + curl = CurlClient(env=self.env, silent=self._silent_curl) + r = curl.http_put(urls=[url], fdata=fpath, alpn_proto=proto, + with_headers=False, with_profile=True) + err = self._check_uploads(r, count) + if err: + errors.append(err) + else: + total_size = sum([s['size_upload'] for s in r.stats]) + samples.append(total_size / r.duration.total_seconds()) + profiles.append(r.profile) + return { + 'count': count, + 'samples': sample_size, + 'max-parallel': 1, + 'speed': mean(samples) if len(samples) else -1, + 'errors': errors, + 'stats': RunProfile.AverageStats(profiles) if len(profiles) else {}, + } + + def upload_serial(self, url: str, proto: str, fpath: str, count: int): + sample_size = 1 + samples = [] + errors = [] + profiles = [] + url = f'{url}?id=[0-{count - 1}]' + self.info(f'serial...') + for i in range(sample_size): + curl = CurlClient(env=self.env, silent=self._silent_curl) + r = curl.http_put(urls=[url], fdata=fpath, alpn_proto=proto, + with_headers=False, with_profile=True) + err = self._check_uploads(r, count) + if err: + errors.append(err) + else: + total_size = sum([s['size_upload'] for s in r.stats]) + samples.append(total_size / r.duration.total_seconds()) + profiles.append(r.profile) + return { + 'count': count, + 'samples': sample_size, + 'max-parallel': 1, + 'speed': mean(samples) if len(samples) else -1, + 'errors': errors, + 'stats': RunProfile.AverageStats(profiles) if len(profiles) else {}, + } + + def upload_parallel(self, url: str, proto: str, fpath: str, count: int): + sample_size = 1 + samples = [] + errors = [] + profiles = [] + max_parallel = count + url = f'{url}?id=[0-{count - 1}]' + self.info(f'parallel...') + for i in range(sample_size): + curl = CurlClient(env=self.env, silent=self._silent_curl) + r = curl.http_put(urls=[url], fdata=fpath, alpn_proto=proto, + with_headers=False, with_profile=True, + extra_args=[ + '--parallel', + '--parallel-max', str(max_parallel) + ]) + err = self._check_uploads(r, count) + if err: + errors.append(err) + else: + total_size = sum([s['size_upload'] for s in r.stats]) + samples.append(total_size / r.duration.total_seconds()) + profiles.append(r.profile) + return { + 'count': count, + 'samples': sample_size, + 'max-parallel': max_parallel, + 'speed': mean(samples) if len(samples) else -1, + 'errors': errors, + 'stats': RunProfile.AverageStats(profiles) if len(profiles) else {}, + } + + def upload_url(self, label: str, url: str, fpath: str, proto: str, count: int): + self.info(f' {count}x{label}: ') + props = { + 'single': self.upload_single(url=url, proto=proto, fpath=fpath, + count=10), + } + if count > 1: + props['serial'] = self.upload_serial(url=url, proto=proto, + fpath=fpath, count=count) + props['parallel'] = self.upload_parallel(url=url, proto=proto, + fpath=fpath, count=count) + self.info(f'ok.\n') + return props + + def uploads(self, proto: str, count: int, + fsizes: List[int]) -> Dict[str, Any]: + scores = {} + if self.httpd: + if proto == 'h3': + port = self.env.h3_port + via = 'nghttpx' + descr = f'port {port}, proxying httpd' + else: + port = self.env.https_port + via = 'httpd' + descr = f'port {port}' + self.info(f'{via} uploads\n') + scores[via] = { + 'description': descr, + } + for fsize in fsizes: + label = self.fmt_size(fsize) + fname = f'upload{label}.data' + fpath = self._make_docs_file(docs_dir=self.env.gen_dir, + fname=fname, fsize=fsize) + url = f'https://{self.env.domain1}:{port}/curltest/put' + results = self.upload_url(label=label, url=url, fpath=fpath, + proto=proto, count=count) + scores[via][label] = results + if self.caddy: + port = self.caddy.port + via = 'caddy' + descr = f'port {port}' + self.info('caddy uploads\n') + scores[via] = { + 'description': descr, + } + for fsize in fsizes: + label = self.fmt_size(fsize) + fname = f'upload{label}.data' + fpath = self._make_docs_file(docs_dir=self.env.gen_dir, + fname=fname, fsize=fsize) + url = f'https://{self.env.domain2}:{port}/curltest/put' + results = self.upload_url(label=label, url=url, fpath=fpath, + proto=proto, count=count) + scores[via][label] = results + return scores + def do_requests(self, url: str, proto: str, count: int, max_parallel: int = 1): sample_size = 1 @@ -346,6 +503,8 @@ class ScoreCard: handshakes: bool = True, downloads: Optional[List[int]] = None, download_count: int = 50, + uploads: Optional[List[int]] = None, + upload_count: int = 50, req_count=5000, requests: bool = True): self.info(f"scoring {proto}\n") @@ -389,6 +548,10 @@ class ScoreCard: score['downloads'] = self.downloads(proto=proto, count=download_count, fsizes=downloads) + if uploads and len(uploads) > 0: + score['uploads'] = self.uploads(proto=proto, + count=upload_count, + fsizes=uploads) if requests: score['requests'] = self.requests(proto=proto, req_count=req_count) self.info("\n") @@ -470,6 +633,53 @@ class ScoreCard: else: print(f' {"-":^20}') + if 'uploads' in score: + # get the key names of all sizes and measurements made + sizes = [] + measures = [] + m_names = {} + mcol_width = 12 + mcol_sw = 17 + for server, server_score in score['uploads'].items(): + for sskey, ssval in server_score.items(): + if isinstance(ssval, str): + continue + if sskey not in sizes: + sizes.append(sskey) + for mkey, mval in server_score[sskey].items(): + if mkey not in measures: + measures.append(mkey) + m_names[mkey] = f'{mkey}({mval["count"]}x{mval["max-parallel"]})' + + print('Uploads') + print(f' {"Server":<8} {"Size":>8}', end='') + for m in measures: print(f' {m_names[m]:>{mcol_width}} {"[cpu/rss]":<{mcol_sw}}', end='') + print(f' {"Errors":^20}') + + for server in score['uploads']: + for size in sizes: + size_score = score['uploads'][server][size] + print(f' {server:<8} {size:>8}', end='') + errors = [] + for key, val in size_score.items(): + if 'errors' in val: + errors.extend(val['errors']) + for m in measures: + if m in size_score: + print(f' {self.fmt_mbs(size_score[m]["speed"]):>{mcol_width}}', end='') + stats = size_score[m]["stats"] + if 'cpu' in stats: + s = f'[{stats["cpu"]:>.1f}%/{self.fmt_size(stats["rss"])}]' + else: + s = '[???/???]' + print(f' {s:<{mcol_sw}}', end='') + else: + print(' '*mcol_width, end='') + if len(errors): + print(f' {"/".join(errors):<20}') + else: + print(f' {"-":^20}') + if 'requests' in score: sizes = [] measures = [] @@ -551,6 +761,12 @@ def main(): default=50, help="perform that many downloads") parser.add_argument("--download-parallel", action='store', type=int, default=0, help="perform that many downloads in parallel (default all)") + parser.add_argument("-u", "--uploads", action='store_true', + default=False, help="evaluate uploads") + parser.add_argument("--upload", action='append', type=str, + default=None, help="evaluate upload size") + parser.add_argument("--upload-count", action='store', type=int, + default=50, help="perform that many uploads") parser.add_argument("-r", "--requests", action='store_true', default=False, help="evaluate requests") parser.add_argument("--request-count", action='store', type=int, @@ -578,11 +794,20 @@ def main(): downloads = [] for x in args.download: downloads.extend([parse_size(s) for s in x.split(',')]) + + uploads = [1024 * 1024, 10 * 1024 * 1024, 100 * 1024 * 1024] + if args.upload is not None: + uploads = [] + for x in args.upload: + uploads.extend([parse_size(s) for s in x.split(',')]) + requests = True - if args.downloads or args.requests or args.handshakes: + if args.downloads or args.uploads or args.requests or args.handshakes: handshakes = args.handshakes if not args.downloads: downloads = None + if not args.uploads: + uploads = None requests = args.requests test_httpd = protocol != 'h3' @@ -599,14 +824,14 @@ def main(): nghttpx = None caddy = None try: - if test_httpd: + if test_httpd or (test_caddy and uploads): print(f'httpd: {env.httpd_version()}, http:{env.http_port} https:{env.https_port}') httpd = Httpd(env=env) assert httpd.exists(), \ f'httpd not found: {env.httpd}' httpd.clear_logs() assert httpd.start() - if 'h3' == protocol: + if test_httpd and 'h3' == protocol: nghttpx = NghttpxQuic(env=env) nghttpx.clear_logs() assert nghttpx.start() @@ -616,13 +841,16 @@ def main(): caddy.clear_logs() assert caddy.start() - card = ScoreCard(env=env, httpd=httpd, nghttpx=nghttpx, caddy=caddy, + card = ScoreCard(env=env, httpd=httpd if test_httpd else None, + nghttpx=nghttpx, caddy=caddy if test_caddy else None, verbose=args.verbose, curl_verbose=args.curl_verbose, download_parallel=args.download_parallel) score = card.score_proto(proto=protocol, handshakes=handshakes, downloads=downloads, download_count=args.download_count, + uploads=uploads, + upload_count=args.upload_count, req_count=args.request_count, requests=requests) if args.json: diff --git a/tests/http/test_08_caddy.py b/tests/http/test_08_caddy.py index 4a75ef5523..1e07664124 100644 --- a/tests/http/test_08_caddy.py +++ b/tests/http/test_08_caddy.py @@ -61,6 +61,7 @@ class TestCaddy: self._make_docs_file(docs_dir=caddy.docs_dir, fname='data5.data', fsize=5*1024*1024) self._make_docs_file(docs_dir=caddy.docs_dir, fname='data10.data', fsize=10*1024*1024) self._make_docs_file(docs_dir=caddy.docs_dir, fname='data100.data', fsize=100*1024*1024) + env.make_data_file(indir=env.gen_dir, fname="data-10m", fsize=10*1024*1024) # download 1 file @pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3']) @@ -167,10 +168,9 @@ class TestCaddy: else: assert r.total_connects == 1, r.dump_logs() - # upload data parallel, check that they were echoed - @pytest.mark.skipif(condition=Env().ci_run, reason="not suitable for CI runs") - @pytest.mark.parametrize("proto", ['h2', 'h3']) - def test_08_06_upload_parallel(self, env: Env, caddy, repeat, proto): + # post data parallel, check that they were echoed + @pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3']) + def test_08_06_post_parallel(self, env: Env, httpd, caddy, repeat, proto): if proto == 'h3' and not env.have_h3(): pytest.skip("h3 not supported") if proto == 'h3' and env.curl_uses_lib('msh3'): @@ -179,8 +179,29 @@ class TestCaddy: count = 20 data = '0123456789' curl = CurlClient(env=env) - url = f'https://{env.domain1}:{caddy.port}/data10.data?[0-{count-1}]' + url = f'https://{env.domain2}:{caddy.port}/curltest/echo?id=[0-{count-1}]' r = curl.http_upload(urls=[url], data=data, alpn_proto=proto, extra_args=['--parallel']) - exp_status = 405 if env.caddy_is_at_least('2.7.0') else 200 - r.check_stats(count=count, http_status=exp_status, exitcode=0) + r.check_stats(count=count, http_status=200, exitcode=0) + for i in range(0,count): + respdata = open(curl.response_file(i)).readlines() + assert respdata == [data] + + # put large file, check that they length were echoed + @pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3']) + def test_08_07_put_large(self, env: Env, httpd, caddy, repeat, proto): + if proto == 'h3' and not env.have_h3(): + pytest.skip("h3 not supported") + if proto == 'h3' and env.curl_uses_lib('msh3'): + pytest.skip("msh3 stalls here") + # limit since we use a separate connection in h1< + count = 1 + fdata = os.path.join(env.gen_dir, 'data-10m') + curl = CurlClient(env=env) + url = f'https://{env.domain2}:{caddy.port}/curltest/put?id=[0-{count-1}]' + r = curl.http_put(urls=[url], fdata=fdata, alpn_proto=proto) + exp_data = [f'{os.path.getsize(fdata)}'] + r.check_response(count=count, http_status=200) + for i in range(count): + respdata = open(curl.response_file(i)).readlines() + assert respdata == exp_data diff --git a/tests/http/testenv/caddy.py b/tests/http/testenv/caddy.py index ea1343a950..12f38a75cb 100644 --- a/tests/http/testenv/caddy.py +++ b/tests/http/testenv/caddy.py @@ -141,6 +141,8 @@ class Caddy: def _write_config(self): domain1 = self.env.domain1 creds1 = self.env.get_credentials(domain1) + domain2 = self.env.domain2 + creds2 = self.env.get_credentials(domain2) self._mkpath(self._docs_dir) self._mkpath(self._tmp_dir) with open(os.path.join(self._docs_dir, 'data.json'), 'w') as fd: @@ -163,5 +165,10 @@ class Caddy: f' }}', f' tls {creds1.cert_file} {creds1.pkey_file}', f'}}', + f'{domain2} {{', + f' reverse_proxy /* http://localhost:{self.env.http_port} {{', + f' }}', + f' tls {creds2.cert_file} {creds2.pkey_file}', + f'}}', ] fd.write("\n".join(conf)) diff --git a/tests/http/testenv/httpd.py b/tests/http/testenv/httpd.py index a8247de4b2..4771ea3606 100644 --- a/tests/http/testenv/httpd.py +++ b/tests/http/testenv/httpd.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +#!/usr/bin/env python3 # -*- coding: utf-8 -*- #*************************************************************************** # _ _ ____ _ @@ -313,6 +314,18 @@ class Httpd: f'', f'', ]) + conf.extend([ # plain http host for domain2 + f'', + f' ServerName {domain2}', + f' ServerAlias localhost', + f' DocumentRoot "{self._docs_dir}"', + f' Protocols h2c http/1.1', + ]) + conf.extend(self._curltest_conf(domain2)) + conf.extend([ + f'', + f'', + ]) conf.extend([ # https host for domain2, no h2 f'', f' ServerName {domain2}', diff --git a/tests/http/testenv/mod_curltest/mod_curltest.c b/tests/http/testenv/mod_curltest/mod_curltest.c index 1c495a2c03..58b9525631 100644 --- a/tests/http/testenv/mod_curltest/mod_curltest.c +++ b/tests/http/testenv/mod_curltest/mod_curltest.c @@ -522,7 +522,7 @@ static int curltest_put_handler(request_rec *r) apr_bucket_brigade *bb; apr_bucket *b; apr_status_t rv; - char buffer[16*1024]; + char buffer[128*1024]; const char *ct; apr_off_t rbody_len = 0; const char *s_rbody_len;