2
0

curl.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. #***************************************************************************
  4. # _ _ ____ _
  5. # Project ___| | | | _ \| |
  6. # / __| | | | |_) | |
  7. # | (__| |_| | _ <| |___
  8. # \___|\___/|_| \_\_____|
  9. #
  10. # Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
  11. #
  12. # This software is licensed as described in the file COPYING, which
  13. # you should have received as part of this distribution. The terms
  14. # are also available at https://curl.se/docs/copyright.html.
  15. #
  16. # You may opt to use, copy, modify, merge, publish, distribute and/or sell
  17. # copies of the Software, and permit persons to whom the Software is
  18. # furnished to do so, under the terms of the COPYING file.
  19. #
  20. # This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
  21. # KIND, either express or implied.
  22. #
  23. # SPDX-License-Identifier: curl
  24. #
  25. ###########################################################################
  26. #
  27. import json
  28. import logging
  29. import os
  30. import sys
  31. import time
  32. from threading import Thread
  33. import psutil
  34. import re
  35. import shutil
  36. import subprocess
  37. from statistics import mean, fmean
  38. from datetime import timedelta, datetime
  39. from typing import List, Optional, Dict, Union, Any
  40. from urllib.parse import urlparse
  41. from .env import Env
  42. log = logging.getLogger(__name__)
  43. class RunProfile:
  44. STAT_KEYS = ['cpu', 'rss', 'vsz']
  45. @classmethod
  46. def AverageStats(cls, profiles: List['RunProfile']):
  47. avg = {}
  48. stats = [p.stats for p in profiles]
  49. for key in cls.STAT_KEYS:
  50. avg[key] = mean([s[key] for s in stats])
  51. return avg
  52. def __init__(self, pid: int, started_at: datetime, run_dir):
  53. self._pid = pid
  54. self._started_at = started_at
  55. self._duration = timedelta(seconds=0)
  56. self._run_dir = run_dir
  57. self._samples = []
  58. self._psu = None
  59. self._stats = None
  60. @property
  61. def duration(self) -> timedelta:
  62. return self._duration
  63. @property
  64. def stats(self) -> Optional[Dict[str,Any]]:
  65. return self._stats
  66. def sample(self):
  67. elapsed = datetime.now() - self._started_at
  68. try:
  69. if self._psu is None:
  70. self._psu = psutil.Process(pid=self._pid)
  71. mem = self._psu.memory_info()
  72. self._samples.append({
  73. 'time': elapsed,
  74. 'cpu': self._psu.cpu_percent(),
  75. 'vsz': mem.vms,
  76. 'rss': mem.rss,
  77. })
  78. except psutil.NoSuchProcess:
  79. pass
  80. def finish(self):
  81. self._duration = datetime.now() - self._started_at
  82. if len(self._samples) > 0:
  83. weights = [s['time'].total_seconds() for s in self._samples]
  84. self._stats = {}
  85. for key in self.STAT_KEYS:
  86. self._stats[key] = fmean([s[key] for s in self._samples], weights)
  87. else:
  88. self._stats = None
  89. self._psu = None
  90. def __repr__(self):
  91. return f'RunProfile[pid={self._pid}, '\
  92. f'duration={self.duration.total_seconds():.3f}s, '\
  93. f'stats={self.stats}]'
  94. class RunTcpDump:
  95. def __init__(self, env, run_dir):
  96. self._env = env
  97. self._run_dir = run_dir
  98. self._proc = None
  99. self._stdoutfile = os.path.join(self._run_dir, 'tcpdump.out')
  100. self._stderrfile = os.path.join(self._run_dir, 'tcpdump.err')
  101. @property
  102. def stats(self) -> Optional[List[str]]:
  103. if self._proc:
  104. raise Exception('tcpdump still running')
  105. lines = []
  106. for l in open(self._stdoutfile).readlines():
  107. if re.match(r'.* IP 127\.0\.0\.1\.\d+ [<>] 127\.0\.0\.1\.\d+:.*', l):
  108. lines.append(l)
  109. return lines
  110. def stats_excluding(self, src_port) -> Optional[List[str]]:
  111. if self._proc:
  112. raise Exception('tcpdump still running')
  113. lines = []
  114. for l in self.stats:
  115. if not re.match(r'.* IP 127\.0\.0\.1\.' + str(src_port) + ' >.*', l):
  116. lines.append(l)
  117. return lines
  118. @property
  119. def stderr(self) -> List[str]:
  120. if self._proc:
  121. raise Exception('tcpdump still running')
  122. lines = []
  123. return open(self._stderrfile).readlines()
  124. def sample(self):
  125. # not sure how to make that detection reliable for all platforms
  126. local_if = 'lo0' if sys.platform.startswith('darwin') else 'lo'
  127. try:
  128. tcpdump = self._env.tcpdump()
  129. if tcpdump is None:
  130. raise Exception('tcpdump not available')
  131. # look with tcpdump for TCP RST packets which indicate
  132. # we did not shut down connections cleanly
  133. args = []
  134. # at least on Linux, we need root permissions to run tcpdump
  135. if sys.platform.startswith('linux'):
  136. args.append('sudo')
  137. args.extend([
  138. tcpdump, '-i', local_if, '-n', 'tcp[tcpflags] & (tcp-rst)!=0'
  139. ])
  140. with open(self._stdoutfile, 'w') as cout:
  141. with open(self._stderrfile, 'w') as cerr:
  142. self._proc = subprocess.Popen(args, stdout=cout, stderr=cerr,
  143. text=True, cwd=self._run_dir,
  144. shell=False)
  145. assert self._proc
  146. assert self._proc.returncode is None
  147. while self._proc:
  148. try:
  149. self._proc.wait(timeout=1)
  150. except subprocess.TimeoutExpired:
  151. pass
  152. except Exception as e:
  153. log.error(f'Tcpdump: {e}')
  154. def start(self):
  155. def do_sample():
  156. self.sample()
  157. t = Thread(target=do_sample)
  158. t.start()
  159. def finish(self):
  160. if self._proc:
  161. time.sleep(1)
  162. self._proc.terminate()
  163. self._proc = None
  164. class ExecResult:
  165. def __init__(self, args: List[str], exit_code: int,
  166. stdout: List[str], stderr: List[str],
  167. duration: Optional[timedelta] = None,
  168. with_stats: bool = False,
  169. exception: Optional[str] = None,
  170. profile: Optional[RunProfile] = None,
  171. tcpdump: Optional[RunTcpDump] = None):
  172. self._args = args
  173. self._exit_code = exit_code
  174. self._exception = exception
  175. self._stdout = stdout
  176. self._stderr = stderr
  177. self._profile = profile
  178. self._tcpdump = tcpdump
  179. self._duration = duration if duration is not None else timedelta()
  180. self._response = None
  181. self._responses = []
  182. self._results = {}
  183. self._assets = []
  184. self._stats = []
  185. self._json_out = None
  186. self._with_stats = with_stats
  187. if with_stats:
  188. self._parse_stats()
  189. else:
  190. # noinspection PyBroadException
  191. try:
  192. out = ''.join(self._stdout)
  193. self._json_out = json.loads(out)
  194. except:
  195. pass
  196. def __repr__(self):
  197. return f"ExecResult[code={self.exit_code}, exception={self._exception}, "\
  198. f"args={self._args}, stdout={self._stdout}, stderr={self._stderr}]"
  199. def _parse_stats(self):
  200. self._stats = []
  201. for l in self._stdout:
  202. try:
  203. self._stats.append(json.loads(l))
  204. except:
  205. log.error(f'not a JSON stat: {l}')
  206. break
  207. @property
  208. def exit_code(self) -> int:
  209. return self._exit_code
  210. @property
  211. def args(self) -> List[str]:
  212. return self._args
  213. @property
  214. def outraw(self) -> bytes:
  215. return ''.join(self._stdout).encode()
  216. @property
  217. def stdout(self) -> str:
  218. return ''.join(self._stdout)
  219. @property
  220. def json(self) -> Optional[Dict]:
  221. """Output as JSON dictionary or None if not parseable."""
  222. return self._json_out
  223. @property
  224. def stderr(self) -> str:
  225. return ''.join(self._stderr)
  226. @property
  227. def trace_lines(self) -> List[str]:
  228. return self._stderr
  229. @property
  230. def duration(self) -> timedelta:
  231. return self._duration
  232. @property
  233. def profile(self) -> Optional[RunProfile]:
  234. return self._profile
  235. @property
  236. def tcpdump(self) -> Optional[RunTcpDump]:
  237. return self._tcpdump
  238. @property
  239. def response(self) -> Optional[Dict]:
  240. return self._response
  241. @property
  242. def responses(self) -> List[Dict]:
  243. return self._responses
  244. @property
  245. def results(self) -> Dict:
  246. return self._results
  247. @property
  248. def assets(self) -> List:
  249. return self._assets
  250. @property
  251. def with_stats(self) -> bool:
  252. return self._with_stats
  253. @property
  254. def stats(self) -> List:
  255. return self._stats
  256. @property
  257. def total_connects(self) -> Optional[int]:
  258. if len(self.stats):
  259. n = 0
  260. for stat in self.stats:
  261. n += stat['num_connects']
  262. return n
  263. return None
  264. def add_response(self, resp: Dict):
  265. self._response = resp
  266. self._responses.append(resp)
  267. def add_results(self, results: Dict):
  268. self._results.update(results)
  269. if 'response' in results:
  270. self.add_response(results['response'])
  271. def add_assets(self, assets: List):
  272. self._assets.extend(assets)
  273. def check_exit_code(self, code: Union[int, bool]):
  274. if code is True:
  275. assert self.exit_code == 0, f'expected exit code {code}, '\
  276. f'got {self.exit_code}\n{self.dump_logs()}'
  277. elif code is False:
  278. assert self.exit_code != 0, f'expected exit code {code}, '\
  279. f'got {self.exit_code}\n{self.dump_logs()}'
  280. else:
  281. assert self.exit_code == code, f'expected exit code {code}, '\
  282. f'got {self.exit_code}\n{self.dump_logs()}'
  283. def check_response(self, http_status: Optional[int] = 200,
  284. count: Optional[int] = 1,
  285. protocol: Optional[str] = None,
  286. exitcode: Optional[int] = 0,
  287. connect_count: Optional[int] = None):
  288. if exitcode:
  289. self.check_exit_code(exitcode)
  290. if self.with_stats and isinstance(exitcode, int):
  291. for idx, x in enumerate(self.stats):
  292. if 'exitcode' in x:
  293. assert int(x['exitcode']) == exitcode, \
  294. f'response #{idx} exitcode: expected {exitcode}, '\
  295. f'got {x["exitcode"]}\n{self.dump_logs()}'
  296. if self.with_stats:
  297. assert len(self.stats) == count, \
  298. f'response count: expected {count}, ' \
  299. f'got {len(self.stats)}\n{self.dump_logs()}'
  300. else:
  301. assert len(self.responses) == count, \
  302. f'response count: expected {count}, ' \
  303. f'got {len(self.responses)}\n{self.dump_logs()}'
  304. if http_status is not None:
  305. if self.with_stats:
  306. for idx, x in enumerate(self.stats):
  307. assert 'http_code' in x, \
  308. f'response #{idx} reports no http_code\n{self.dump_stat(x)}'
  309. assert x['http_code'] == http_status, \
  310. f'response #{idx} http_code: expected {http_status}, '\
  311. f'got {x["http_code"]}\n{self.dump_stat(x)}'
  312. else:
  313. for idx, x in enumerate(self.responses):
  314. assert x['status'] == http_status, \
  315. f'response #{idx} status: expected {http_status},'\
  316. f'got {x["status"]}\n{self.dump_stat(x)}'
  317. if protocol is not None:
  318. if self.with_stats:
  319. http_version = None
  320. if protocol == 'HTTP/1.1':
  321. http_version = '1.1'
  322. elif protocol == 'HTTP/2':
  323. http_version = '2'
  324. elif protocol == 'HTTP/3':
  325. http_version = '3'
  326. if http_version is not None:
  327. for idx, x in enumerate(self.stats):
  328. assert x['http_version'] == http_version, \
  329. f'response #{idx} protocol: expected http/{http_version},' \
  330. f'got version {x["http_version"]}\n{self.dump_stat(x)}'
  331. else:
  332. for idx, x in enumerate(self.responses):
  333. assert x['protocol'] == protocol, \
  334. f'response #{idx} protocol: expected {protocol},'\
  335. f'got {x["protocol"]}\n{self.dump_logs()}'
  336. if connect_count is not None:
  337. assert self.total_connects == connect_count, \
  338. f'expected {connect_count}, but {self.total_connects} '\
  339. f'were made\n{self.dump_logs()}'
  340. def check_stats(self, count: int, http_status: Optional[int] = None,
  341. exitcode: Optional[int] = None):
  342. if exitcode is None:
  343. self.check_exit_code(0)
  344. assert len(self.stats) == count, \
  345. f'stats count: expected {count}, got {len(self.stats)}\n{self.dump_logs()}'
  346. if http_status is not None:
  347. for idx, x in enumerate(self.stats):
  348. assert 'http_code' in x, \
  349. f'status #{idx} reports no http_code\n{self.dump_stat(x)}'
  350. assert x['http_code'] == http_status, \
  351. f'status #{idx} http_code: expected {http_status}, '\
  352. f'got {x["http_code"]}\n{self.dump_stat(x)}'
  353. if exitcode is not None:
  354. for idx, x in enumerate(self.stats):
  355. if 'exitcode' in x:
  356. assert x['exitcode'] == exitcode, \
  357. f'status #{idx} exitcode: expected {exitcode}, '\
  358. f'got {x["exitcode"]}\n{self.dump_stat(x)}'
  359. def dump_logs(self):
  360. lines = ['>>--stdout ----------------------------------------------\n']
  361. lines.extend(self._stdout)
  362. lines.append('>>--stderr ----------------------------------------------\n')
  363. lines.extend(self._stderr)
  364. lines.append('<<-------------------------------------------------------\n')
  365. return ''.join(lines)
  366. def dump_stat(self, x):
  367. lines = [
  368. 'json stat from curl:',
  369. json.JSONEncoder(indent=2).encode(x),
  370. ]
  371. if 'xfer_id' in x:
  372. xfer_id = x['xfer_id']
  373. lines.append(f'>>--xfer {xfer_id} trace:\n')
  374. lines.extend(self.xfer_trace_for(xfer_id))
  375. else:
  376. lines.append('>>--full trace-------------------------------------------\n')
  377. lines.extend(self._stderr)
  378. lines.append('<<-------------------------------------------------------\n')
  379. return ''.join(lines)
  380. def xfer_trace_for(self, xfer_id) -> List[str]:
  381. pat = re.compile(f'^[^[]* \\[{xfer_id}-.*$')
  382. return [line for line in self._stderr if pat.match(line)]
  383. class CurlClient:
  384. ALPN_ARG = {
  385. 'http/0.9': '--http0.9',
  386. 'http/1.0': '--http1.0',
  387. 'http/1.1': '--http1.1',
  388. 'h2': '--http2',
  389. 'h2c': '--http2',
  390. 'h3': '--http3-only',
  391. }
  392. def __init__(self, env: Env,
  393. run_dir: Optional[str] = None,
  394. timeout: Optional[float] = None,
  395. silent: bool = False,
  396. run_env: Optional[Dict[str, str]] = None):
  397. self.env = env
  398. self._timeout = timeout if timeout else env.test_timeout
  399. self._curl = os.environ['CURL'] if 'CURL' in os.environ else env.curl
  400. self._run_dir = run_dir if run_dir else os.path.join(env.gen_dir, 'curl')
  401. self._stdoutfile = f'{self._run_dir}/curl.stdout'
  402. self._stderrfile = f'{self._run_dir}/curl.stderr'
  403. self._headerfile = f'{self._run_dir}/curl.headers'
  404. self._log_path = f'{self._run_dir}/curl.log'
  405. self._silent = silent
  406. self._run_env = run_env
  407. self._rmrf(self._run_dir)
  408. self._mkpath(self._run_dir)
  409. @property
  410. def run_dir(self) -> str:
  411. return self._run_dir
  412. def download_file(self, i: int) -> str:
  413. return os.path.join(self.run_dir, f'download_{i}.data')
  414. def _rmf(self, path):
  415. if os.path.exists(path):
  416. return os.remove(path)
  417. def _rmrf(self, path):
  418. if os.path.exists(path):
  419. return shutil.rmtree(path)
  420. def _mkpath(self, path):
  421. if not os.path.exists(path):
  422. return os.makedirs(path)
  423. def get_proxy_args(self, proto: str = 'http/1.1',
  424. proxys: bool = True, tunnel: bool = False,
  425. use_ip: bool = False):
  426. proxy_name = '127.0.0.1' if use_ip else self.env.proxy_domain
  427. if proxys:
  428. pport = self.env.pts_port(proto) if tunnel else self.env.proxys_port
  429. xargs = [
  430. '--proxy', f'https://{proxy_name}:{pport}/',
  431. '--resolve', f'{proxy_name}:{pport}:127.0.0.1',
  432. '--proxy-cacert', self.env.ca.cert_file,
  433. ]
  434. if proto == 'h2':
  435. xargs.append('--proxy-http2')
  436. else:
  437. xargs = [
  438. '--proxy', f'http://{proxy_name}:{self.env.proxy_port}/',
  439. '--resolve', f'{proxy_name}:{self.env.proxy_port}:127.0.0.1',
  440. ]
  441. if tunnel:
  442. xargs.append('--proxytunnel')
  443. return xargs
  444. def http_get(self, url: str, extra_args: Optional[List[str]] = None,
  445. alpn_proto: Optional[str] = None,
  446. def_tracing: bool = True,
  447. with_stats: bool = False,
  448. with_profile: bool = False,
  449. with_tcpdump: bool = False):
  450. return self._raw(url, options=extra_args,
  451. with_stats=with_stats,
  452. alpn_proto=alpn_proto,
  453. def_tracing=def_tracing,
  454. with_profile=with_profile,
  455. with_tcpdump=with_tcpdump)
  456. def http_download(self, urls: List[str],
  457. alpn_proto: Optional[str] = None,
  458. with_stats: bool = True,
  459. with_headers: bool = False,
  460. with_profile: bool = False,
  461. with_tcpdump: bool = False,
  462. no_save: bool = False,
  463. extra_args: List[str] = None):
  464. if extra_args is None:
  465. extra_args = []
  466. if no_save:
  467. extra_args.extend([
  468. '-o', '/dev/null',
  469. ])
  470. else:
  471. extra_args.extend([
  472. '-o', 'download_#1.data',
  473. ])
  474. # remove any existing ones
  475. for i in range(100):
  476. self._rmf(self.download_file(i))
  477. if with_stats:
  478. extra_args.extend([
  479. '-w', '%{json}\\n'
  480. ])
  481. return self._raw(urls, alpn_proto=alpn_proto, options=extra_args,
  482. with_stats=with_stats,
  483. with_headers=with_headers,
  484. with_profile=with_profile,
  485. with_tcpdump=with_tcpdump)
  486. def http_upload(self, urls: List[str], data: str,
  487. alpn_proto: Optional[str] = None,
  488. with_stats: bool = True,
  489. with_headers: bool = False,
  490. with_profile: bool = False,
  491. with_tcpdump: bool = False,
  492. extra_args: Optional[List[str]] = None):
  493. if extra_args is None:
  494. extra_args = []
  495. extra_args.extend([
  496. '--data-binary', data, '-o', 'download_#1.data',
  497. ])
  498. if with_stats:
  499. extra_args.extend([
  500. '-w', '%{json}\\n'
  501. ])
  502. return self._raw(urls, alpn_proto=alpn_proto, options=extra_args,
  503. with_stats=with_stats,
  504. with_headers=with_headers,
  505. with_profile=with_profile,
  506. with_tcpdump=with_tcpdump)
  507. def http_delete(self, urls: List[str],
  508. alpn_proto: Optional[str] = None,
  509. with_stats: bool = True,
  510. with_profile: bool = False,
  511. extra_args: Optional[List[str]] = None):
  512. if extra_args is None:
  513. extra_args = []
  514. extra_args.extend([
  515. '-X', 'DELETE', '-o', '/dev/null',
  516. ])
  517. if with_stats:
  518. extra_args.extend([
  519. '-w', '%{json}\\n'
  520. ])
  521. return self._raw(urls, alpn_proto=alpn_proto, options=extra_args,
  522. with_stats=with_stats,
  523. with_headers=False,
  524. with_profile=with_profile)
  525. def http_put(self, urls: List[str], data=None, fdata=None,
  526. alpn_proto: Optional[str] = None,
  527. with_stats: bool = True,
  528. with_headers: bool = False,
  529. with_profile: bool = False,
  530. extra_args: Optional[List[str]] = None):
  531. if extra_args is None:
  532. extra_args = []
  533. if fdata is not None:
  534. extra_args.extend(['-T', fdata])
  535. elif data is not None:
  536. extra_args.extend(['-T', '-'])
  537. extra_args.extend([
  538. '-o', 'download_#1.data',
  539. ])
  540. if with_stats:
  541. extra_args.extend([
  542. '-w', '%{json}\\n'
  543. ])
  544. return self._raw(urls, intext=data,
  545. alpn_proto=alpn_proto, options=extra_args,
  546. with_stats=with_stats,
  547. with_headers=with_headers,
  548. with_profile=with_profile)
  549. def http_form(self, urls: List[str], form: Dict[str, str],
  550. alpn_proto: Optional[str] = None,
  551. with_stats: bool = True,
  552. with_headers: bool = False,
  553. extra_args: Optional[List[str]] = None):
  554. if extra_args is None:
  555. extra_args = []
  556. for key, val in form.items():
  557. extra_args.extend(['-F', f'{key}={val}'])
  558. extra_args.extend([
  559. '-o', 'download_#1.data',
  560. ])
  561. if with_stats:
  562. extra_args.extend([
  563. '-w', '%{json}\\n'
  564. ])
  565. return self._raw(urls, alpn_proto=alpn_proto, options=extra_args,
  566. with_stats=with_stats,
  567. with_headers=with_headers)
  568. def ftp_get(self, urls: List[str],
  569. with_stats: bool = True,
  570. with_profile: bool = False,
  571. with_tcpdump: bool = False,
  572. no_save: bool = False,
  573. extra_args: List[str] = None):
  574. if extra_args is None:
  575. extra_args = []
  576. if no_save:
  577. extra_args.extend([
  578. '-o', '/dev/null',
  579. ])
  580. else:
  581. extra_args.extend([
  582. '-o', 'download_#1.data',
  583. ])
  584. # remove any existing ones
  585. for i in range(100):
  586. self._rmf(self.download_file(i))
  587. if with_stats:
  588. extra_args.extend([
  589. '-w', '%{json}\\n'
  590. ])
  591. return self._raw(urls, options=extra_args,
  592. with_stats=with_stats,
  593. with_headers=False,
  594. with_profile=with_profile,
  595. with_tcpdump=with_tcpdump)
  596. def ftp_ssl_get(self, urls: List[str],
  597. with_stats: bool = True,
  598. with_profile: bool = False,
  599. with_tcpdump: bool = False,
  600. no_save: bool = False,
  601. extra_args: List[str] = None):
  602. if extra_args is None:
  603. extra_args = []
  604. extra_args.extend([
  605. '--ssl-reqd',
  606. ])
  607. return self.ftp_get(urls=urls, with_stats=with_stats,
  608. with_profile=with_profile, no_save=no_save,
  609. with_tcpdump=with_tcpdump,
  610. extra_args=extra_args)
  611. def ftp_upload(self, urls: List[str], fupload,
  612. with_stats: bool = True,
  613. with_profile: bool = False,
  614. with_tcpdump: bool = False,
  615. extra_args: List[str] = None):
  616. if extra_args is None:
  617. extra_args = []
  618. extra_args.extend([
  619. '--upload-file', fupload
  620. ])
  621. if with_stats:
  622. extra_args.extend([
  623. '-w', '%{json}\\n'
  624. ])
  625. return self._raw(urls, options=extra_args,
  626. with_stats=with_stats,
  627. with_headers=False,
  628. with_profile=with_profile,
  629. with_tcpdump=with_tcpdump)
  630. def ftp_ssl_upload(self, urls: List[str], fupload,
  631. with_stats: bool = True,
  632. with_profile: bool = False,
  633. with_tcpdump: bool = False,
  634. extra_args: List[str] = None):
  635. if extra_args is None:
  636. extra_args = []
  637. extra_args.extend([
  638. '--ssl-reqd',
  639. ])
  640. return self.ftp_upload(urls=urls, fupload=fupload,
  641. with_stats=with_stats, with_profile=with_profile,
  642. with_tcpdump=with_tcpdump,
  643. extra_args=extra_args)
  644. def response_file(self, idx: int):
  645. return os.path.join(self._run_dir, f'download_{idx}.data')
  646. def run_direct(self, args, with_stats: bool = False, with_profile: bool = False):
  647. my_args = [self._curl]
  648. if with_stats:
  649. my_args.extend([
  650. '-w', '%{json}\\n'
  651. ])
  652. my_args.extend([
  653. '-o', 'download.data',
  654. ])
  655. my_args.extend(args)
  656. return self._run(args=my_args, with_stats=with_stats, with_profile=with_profile)
  657. def _run(self, args, intext='', with_stats: bool = False,
  658. with_profile: bool = True, with_tcpdump: bool = False):
  659. self._rmf(self._stdoutfile)
  660. self._rmf(self._stderrfile)
  661. self._rmf(self._headerfile)
  662. exception = None
  663. profile = None
  664. tcpdump = None
  665. started_at = datetime.now()
  666. if with_tcpdump:
  667. tcpdump = RunTcpDump(self.env, self._run_dir)
  668. tcpdump.start()
  669. try:
  670. with open(self._stdoutfile, 'w') as cout:
  671. with open(self._stderrfile, 'w') as cerr:
  672. if with_profile:
  673. end_at = started_at + timedelta(seconds=self._timeout) \
  674. if self._timeout else None
  675. log.info(f'starting: {args}')
  676. p = subprocess.Popen(args, stderr=cerr, stdout=cout,
  677. cwd=self._run_dir, shell=False,
  678. env=self._run_env)
  679. profile = RunProfile(p.pid, started_at, self._run_dir)
  680. if intext is not None and False:
  681. p.communicate(input=intext.encode(), timeout=1)
  682. ptimeout = 0.0
  683. while True:
  684. try:
  685. p.wait(timeout=ptimeout)
  686. break
  687. except subprocess.TimeoutExpired:
  688. if end_at and datetime.now() >= end_at:
  689. p.kill()
  690. raise subprocess.TimeoutExpired(cmd=args, timeout=self._timeout)
  691. profile.sample()
  692. ptimeout = 0.01
  693. exitcode = p.returncode
  694. profile.finish()
  695. log.info(f'done: exit={exitcode}, profile={profile}')
  696. else:
  697. p = subprocess.run(args, stderr=cerr, stdout=cout,
  698. cwd=self._run_dir, shell=False,
  699. input=intext.encode() if intext else None,
  700. timeout=self._timeout,
  701. env=self._run_env)
  702. exitcode = p.returncode
  703. except subprocess.TimeoutExpired:
  704. now = datetime.now()
  705. duration = now - started_at
  706. log.warning(f'Timeout at {now} after {duration.total_seconds()}s '
  707. f'(configured {self._timeout}s): {args}')
  708. exitcode = -1
  709. exception = 'TimeoutExpired'
  710. if tcpdump:
  711. tcpdump.finish()
  712. coutput = open(self._stdoutfile).readlines()
  713. cerrput = open(self._stderrfile).readlines()
  714. return ExecResult(args=args, exit_code=exitcode, exception=exception,
  715. stdout=coutput, stderr=cerrput,
  716. duration=datetime.now() - started_at,
  717. with_stats=with_stats,
  718. profile=profile, tcpdump=tcpdump)
  719. def _raw(self, urls, intext='', timeout=None, options=None, insecure=False,
  720. alpn_proto: Optional[str] = None,
  721. force_resolve=True,
  722. with_stats=False,
  723. with_headers=True,
  724. def_tracing=True,
  725. with_profile=False,
  726. with_tcpdump=False):
  727. args = self._complete_args(
  728. urls=urls, timeout=timeout, options=options, insecure=insecure,
  729. alpn_proto=alpn_proto, force_resolve=force_resolve,
  730. with_headers=with_headers, def_tracing=def_tracing)
  731. r = self._run(args, intext=intext, with_stats=with_stats,
  732. with_profile=with_profile, with_tcpdump=with_tcpdump)
  733. if r.exit_code == 0 and with_headers:
  734. self._parse_headerfile(self._headerfile, r=r)
  735. if r.json:
  736. r.response["json"] = r.json
  737. return r
  738. def _complete_args(self, urls, timeout=None, options=None,
  739. insecure=False, force_resolve=True,
  740. alpn_proto: Optional[str] = None,
  741. with_headers: bool = True,
  742. def_tracing: bool = True):
  743. if not isinstance(urls, list):
  744. urls = [urls]
  745. args = [self._curl, "-s", "--path-as-is"]
  746. if with_headers:
  747. args.extend(["-D", self._headerfile])
  748. if def_tracing is not False and not self._silent:
  749. args.extend(['-v', '--trace-ids', '--trace-time'])
  750. if self.env.verbose > 1:
  751. args.extend(['--trace-config', 'http/2,http/3,h2-proxy,h1-proxy'])
  752. pass
  753. active_options = options
  754. if options is not None and '--next' in options:
  755. active_options = options[options.index('--next') + 1:]
  756. for url in urls:
  757. u = urlparse(urls[0])
  758. if options:
  759. args.extend(options)
  760. if alpn_proto is not None:
  761. if alpn_proto not in self.ALPN_ARG:
  762. raise Exception(f'unknown ALPN protocol: "{alpn_proto}"')
  763. args.append(self.ALPN_ARG[alpn_proto])
  764. if u.scheme == 'http':
  765. pass
  766. elif insecure:
  767. args.append('--insecure')
  768. elif active_options and "--cacert" in active_options:
  769. pass
  770. elif u.hostname:
  771. args.extend(["--cacert", self.env.ca.cert_file])
  772. if force_resolve and u.hostname and u.hostname != 'localhost' \
  773. and not re.match(r'^(\d+|\[|:).*', u.hostname):
  774. port = u.port if u.port else 443
  775. args.extend(["--resolve", f"{u.hostname}:{port}:127.0.0.1"])
  776. if timeout is not None and int(timeout) > 0:
  777. args.extend(["--connect-timeout", str(int(timeout))])
  778. args.append(url)
  779. return args
  780. def _parse_headerfile(self, headerfile: str, r: ExecResult = None) -> ExecResult:
  781. lines = open(headerfile).readlines()
  782. if r is None:
  783. r = ExecResult(args=[], exit_code=0, stdout=[], stderr=[])
  784. response = None
  785. def fin_response(resp):
  786. if resp:
  787. r.add_response(resp)
  788. expected = ['status']
  789. for line in lines:
  790. line = line.strip()
  791. if re.match(r'^$', line):
  792. if 'trailer' in expected:
  793. # end of trailers
  794. fin_response(response)
  795. response = None
  796. expected = ['status']
  797. elif 'header' in expected:
  798. # end of header, another status or trailers might follow
  799. expected = ['status', 'trailer']
  800. else:
  801. assert False, f"unexpected line: '{line}'"
  802. continue
  803. if 'status' in expected:
  804. # log.debug("reading 1st response line: %s", line)
  805. m = re.match(r'^(\S+) (\d+)( .*)?$', line)
  806. if m:
  807. fin_response(response)
  808. response = {
  809. "protocol": m.group(1),
  810. "status": int(m.group(2)),
  811. "description": m.group(3),
  812. "header": {},
  813. "trailer": {},
  814. "body": r.outraw
  815. }
  816. expected = ['header']
  817. continue
  818. if 'trailer' in expected:
  819. m = re.match(r'^([^:]+):\s*(.*)$', line)
  820. if m:
  821. response['trailer'][m.group(1).lower()] = m.group(2)
  822. continue
  823. if 'header' in expected:
  824. m = re.match(r'^([^:]+):\s*(.*)$', line)
  825. if m:
  826. response['header'][m.group(1).lower()] = m.group(2)
  827. continue
  828. assert False, f"unexpected line: '{line}, expected: {expected}'"
  829. fin_response(response)
  830. return r