serve_header.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. #!/usr/bin/env python3
  2. import contextlib
  3. import logging
  4. import os
  5. import re
  6. import shutil
  7. import sys
  8. import subprocess
  9. from datetime import datetime, timedelta
  10. from io import BytesIO
  11. from threading import Lock, Timer
  12. from watchdog.events import FileSystemEventHandler
  13. from watchdog.observers import Observer
  14. from http import HTTPStatus
  15. from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
  16. CONFIG_FILE = 'serve_header.yml'
  17. MAKEFILE = 'Makefile'
  18. INCLUDE = 'include/nlohmann/'
  19. SINGLE_INCLUDE = 'single_include/nlohmann/'
  20. HEADER = 'json.hpp'
  21. DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S'
  22. JSON_VERSION_RE = re.compile(r'\s*#\s*define\s+NLOHMANN_JSON_VERSION_MAJOR\s+')
  23. class ExitHandler(logging.StreamHandler):
  24. def __init__(self, level):
  25. """."""
  26. super().__init__()
  27. self.level = level
  28. def emit(self, record):
  29. if record.levelno >= self.level:
  30. sys.exit(1)
  31. def is_project_root(test_dir='.'):
  32. makefile = os.path.join(test_dir, MAKEFILE)
  33. include = os.path.join(test_dir, INCLUDE)
  34. single_include = os.path.join(test_dir, SINGLE_INCLUDE)
  35. return (os.path.exists(makefile)
  36. and os.path.isfile(makefile)
  37. and os.path.exists(include)
  38. and os.path.exists(single_include))
  39. class DirectoryEventBucket:
  40. def __init__(self, callback, delay=1.2, threshold=0.8):
  41. """."""
  42. self.delay = delay
  43. self.threshold = timedelta(seconds=threshold)
  44. self.callback = callback
  45. self.event_dirs = set([])
  46. self.timer = None
  47. self.lock = Lock()
  48. def start_timer(self):
  49. if self.timer is None:
  50. self.timer = Timer(self.delay, self.process_dirs)
  51. self.timer.start()
  52. def process_dirs(self):
  53. result_dirs = []
  54. event_dirs = set([])
  55. with self.lock:
  56. self.timer = None
  57. while self.event_dirs:
  58. time, event_dir = self.event_dirs.pop()
  59. delta = datetime.now() - time
  60. if delta < self.threshold:
  61. event_dirs.add((time, event_dir))
  62. else:
  63. result_dirs.append(event_dir)
  64. self.event_dirs = event_dirs
  65. if result_dirs:
  66. self.callback(os.path.commonpath(result_dirs))
  67. if self.event_dirs:
  68. self.start_timer()
  69. def add_dir(self, path):
  70. with self.lock:
  71. # add path to the set of event_dirs if it is not a sibling of
  72. # a directory already in the set
  73. if not any(os.path.commonpath([path, event_dir]) == event_dir
  74. for (_, event_dir) in self.event_dirs):
  75. self.event_dirs.add((datetime.now(), path))
  76. if self.timer is None:
  77. self.start_timer()
  78. class WorkTree:
  79. make_command = 'make'
  80. def __init__(self, root_dir, tree_dir):
  81. """."""
  82. self.root_dir = root_dir
  83. self.tree_dir = tree_dir
  84. self.rel_dir = os.path.relpath(tree_dir, root_dir)
  85. self.name = os.path.basename(tree_dir)
  86. self.include_dir = os.path.abspath(os.path.join(tree_dir, INCLUDE))
  87. self.header = os.path.abspath(os.path.join(tree_dir, SINGLE_INCLUDE, HEADER))
  88. self.rel_header = os.path.relpath(self.header, root_dir)
  89. self.dirty = True
  90. self.build_count = 0
  91. t = os.path.getmtime(self.header)
  92. t = datetime.fromtimestamp(t)
  93. self.build_time = t.strftime(DATETIME_FORMAT)
  94. def __hash__(self):
  95. """."""
  96. return hash((self.tree_dir))
  97. def __eq__(self, other):
  98. """."""
  99. if not isinstance(other, type(self)):
  100. return NotImplemented
  101. return self.tree_dir == other.tree_dir
  102. def update_dirty(self, path):
  103. if self.dirty:
  104. return
  105. path = os.path.abspath(path)
  106. if os.path.commonpath([path, self.include_dir]) == self.include_dir:
  107. logging.info(f'{self.name}: working tree marked dirty')
  108. self.dirty = True
  109. def amalgamate_header(self):
  110. if not self.dirty:
  111. return
  112. mtime = os.path.getmtime(self.header)
  113. subprocess.run([WorkTree.make_command, 'amalgamate'], cwd=self.tree_dir,
  114. stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  115. if mtime == os.path.getmtime(self.header):
  116. logging.info(f'{self.name}: no changes')
  117. else:
  118. self.build_count += 1
  119. self.build_time = datetime.now().strftime(DATETIME_FORMAT)
  120. logging.info(f'{self.name}: header amalgamated (build count {self.build_count})')
  121. self.dirty = False
  122. class WorkTrees(FileSystemEventHandler):
  123. def __init__(self, root_dir):
  124. """."""
  125. super().__init__()
  126. self.root_dir = root_dir
  127. self.trees = set([])
  128. self.tree_lock = Lock()
  129. self.scan(root_dir)
  130. self.created_bucket = DirectoryEventBucket(self.scan)
  131. self.observer = Observer()
  132. self.observer.schedule(self, root_dir, recursive=True)
  133. self.observer.start()
  134. def scan(self, base_dir):
  135. scan_dirs = set([base_dir])
  136. # recursively scan base_dir for working trees
  137. while scan_dirs:
  138. scan_dir = os.path.abspath(scan_dirs.pop())
  139. self.scan_tree(scan_dir)
  140. try:
  141. with os.scandir(scan_dir) as dir_it:
  142. for entry in dir_it:
  143. if entry.is_dir():
  144. scan_dirs.add(entry.path)
  145. except FileNotFoundError as e:
  146. logging.debug('path disappeared: %s', e)
  147. def scan_tree(self, scan_dir):
  148. if not is_project_root(scan_dir):
  149. return
  150. # skip source trees in build directories
  151. # this check could be enhanced
  152. if scan_dir.endswith('/_deps/json-src'):
  153. return
  154. tree = WorkTree(self.root_dir, scan_dir)
  155. with self.tree_lock:
  156. if not tree in self.trees:
  157. if tree.name == tree.rel_dir:
  158. logging.info(f'adding working tree {tree.name}')
  159. else:
  160. logging.info(f'adding working tree {tree.name} at {tree.rel_dir}')
  161. url = os.path.join('/', tree.rel_dir, HEADER)
  162. logging.info(f'{tree.name}: serving header at {url}')
  163. self.trees.add(tree)
  164. def rescan(self, path=None):
  165. if path is not None:
  166. path = os.path.abspath(path)
  167. trees = set([])
  168. # check if any working trees have been removed
  169. with self.tree_lock:
  170. while self.trees:
  171. tree = self.trees.pop()
  172. if ((path is None
  173. or os.path.commonpath([path, tree.tree_dir]) == tree.tree_dir)
  174. and not is_project_root(tree.tree_dir)):
  175. if tree.name == tree.rel_dir:
  176. logging.info(f'removing working tree {tree.name}')
  177. else:
  178. logging.info(f'removing working tree {tree.name} at {tree.rel_dir}')
  179. else:
  180. trees.add(tree)
  181. self.trees = trees
  182. def find(self, path):
  183. # find working tree for a given header file path
  184. path = os.path.abspath(path)
  185. with self.tree_lock:
  186. for tree in self.trees:
  187. if path == tree.header:
  188. return tree
  189. return None
  190. def on_any_event(self, event):
  191. logging.debug('%s (is_dir=%s): %s', event.event_type,
  192. event.is_directory, event.src_path)
  193. path = os.path.abspath(event.src_path)
  194. if event.is_directory:
  195. if event.event_type == 'created':
  196. # check for new working trees
  197. self.created_bucket.add_dir(path)
  198. elif event.event_type == 'deleted':
  199. # check for deleted working trees
  200. self.rescan(path)
  201. elif event.event_type == 'closed':
  202. with self.tree_lock:
  203. for tree in self.trees:
  204. tree.update_dirty(path)
  205. def stop(self):
  206. self.observer.stop()
  207. self.observer.join()
  208. class HeaderRequestHandler(SimpleHTTPRequestHandler): # lgtm[py/missing-call-to-init]
  209. def __init__(self, request, client_address, server):
  210. """."""
  211. self.worktrees = server.worktrees
  212. self.worktree = None
  213. try:
  214. super().__init__(request, client_address, server,
  215. directory=server.worktrees.root_dir)
  216. except ConnectionResetError:
  217. logging.debug('connection reset by peer')
  218. def translate_path(self, path):
  219. path = os.path.abspath(super().translate_path(path))
  220. # add single_include/nlohmann into path, if needed
  221. header = os.path.join('/', HEADER)
  222. header_path = os.path.join('/', SINGLE_INCLUDE, HEADER)
  223. if (path.endswith(header)
  224. and not path.endswith(header_path)):
  225. path = os.path.join(os.path.dirname(path), SINGLE_INCLUDE, HEADER)
  226. return path
  227. def send_head(self):
  228. # check if the translated path matches a working tree
  229. # and fullfill the request; otherwise, send 404
  230. path = self.translate_path(self.path)
  231. self.worktree = self.worktrees.find(path)
  232. if self.worktree is not None:
  233. self.worktree.amalgamate_header()
  234. logging.info(f'{self.worktree.name}; serving header (build count {self.worktree.build_count})')
  235. return super().send_head()
  236. logging.info(f'invalid request path: {self.path}')
  237. super().send_error(HTTPStatus.NOT_FOUND, 'Not Found')
  238. return None
  239. def send_header(self, keyword, value):
  240. # intercept Content-Length header; sent in copyfile later
  241. if keyword == 'Content-Length':
  242. return
  243. super().send_header(keyword, value)
  244. def end_headers (self):
  245. # intercept; called in copyfile() or indirectly
  246. # by send_head via super().send_error()
  247. pass
  248. def copyfile(self, source, outputfile):
  249. injected = False
  250. content = BytesIO()
  251. length = 0
  252. # inject build count and time into served header
  253. for line in source:
  254. line = line.decode('utf-8')
  255. if not injected and JSON_VERSION_RE.match(line):
  256. length += content.write(bytes('#define JSON_BUILD_COUNT '\
  257. f'{self.worktree.build_count}\n', 'utf-8'))
  258. length += content.write(bytes('#define JSON_BUILD_TIME '\
  259. f'"{self.worktree.build_time}"\n\n', 'utf-8'))
  260. injected = True
  261. length += content.write(bytes(line, 'utf-8'))
  262. # set content length
  263. super().send_header('Content-Length', length)
  264. # CORS header
  265. self.send_header('Access-Control-Allow-Origin', '*')
  266. # prevent caching
  267. self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
  268. self.send_header('Pragma', 'no-cache')
  269. self.send_header('Expires', '0')
  270. super().end_headers()
  271. # send the header
  272. content.seek(0)
  273. shutil.copyfileobj(content, outputfile)
  274. def log_message(self, format, *args):
  275. pass
  276. class DualStackServer(ThreadingHTTPServer):
  277. def __init__(self, addr, worktrees):
  278. """."""
  279. self.worktrees = worktrees
  280. super().__init__(addr, HeaderRequestHandler)
  281. def server_bind(self):
  282. # suppress exception when protocol is IPv4
  283. with contextlib.suppress(Exception):
  284. self.socket.setsockopt(
  285. socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
  286. return super().server_bind()
  287. if __name__ == '__main__':
  288. import argparse
  289. import ssl
  290. import socket
  291. import yaml
  292. # exit code
  293. ec = 0
  294. # setup logging
  295. logging.basicConfig(format='[%(asctime)s] %(levelname)s: %(message)s',
  296. datefmt=DATETIME_FORMAT, level=logging.INFO)
  297. log = logging.getLogger()
  298. log.addHandler(ExitHandler(logging.ERROR))
  299. # parse command line arguments
  300. parser = argparse.ArgumentParser()
  301. parser.add_argument('--make', default='make',
  302. help='the make command (default: make)')
  303. args = parser.parse_args()
  304. # propagate the make command to use for amalgamating headers
  305. WorkTree.make_command = args.make
  306. worktrees = None
  307. try:
  308. # change working directory to project root
  309. os.chdir(os.path.realpath(os.path.join(sys.path[0], '../../')))
  310. if not is_project_root():
  311. log.error('working directory does not look like project root')
  312. # load config
  313. config = {}
  314. config_file = os.path.abspath(CONFIG_FILE)
  315. try:
  316. with open(config_file, 'r') as f:
  317. config = yaml.safe_load(f)
  318. except FileNotFoundError:
  319. log.info(f'cannot find configuration file: {config_file}')
  320. log.info('using default configuration')
  321. # find and monitor working trees
  322. worktrees = WorkTrees(config.get('root', '.'))
  323. # start web server
  324. infos = socket.getaddrinfo(config.get('bind', None), config.get('port', 8443),
  325. type=socket.SOCK_STREAM, flags=socket.AI_PASSIVE)
  326. DualStackServer.address_family = infos[0][0]
  327. HeaderRequestHandler.protocol_version = 'HTTP/1.0'
  328. with DualStackServer(infos[0][4], worktrees) as httpd:
  329. scheme = 'HTTP'
  330. https = config.get('https', {})
  331. if https.get('enabled', True):
  332. cert_file = https.get('cert_file', 'localhost.pem')
  333. key_file = https.get('key_file', 'localhost-key.pem')
  334. ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
  335. ssl_ctx.minimum_version = ssl.TLSVersion.TLSv1_2
  336. ssl_ctx.maximum_version = ssl.TLSVersion.MAXIMUM_SUPPORTED
  337. ssl_ctx.load_cert_chain(cert_file, key_file)
  338. httpd.socket = ssl_ctx.wrap_socket(httpd.socket, server_side=True)
  339. scheme = 'HTTPS'
  340. host, port = httpd.socket.getsockname()[:2]
  341. log.info(f'serving {scheme} on {host} port {port}')
  342. log.info('press Ctrl+C to exit')
  343. httpd.serve_forever()
  344. except KeyboardInterrupt:
  345. log.info('exiting')
  346. except Exception:
  347. ec = 1
  348. log.exception('an error occurred:')
  349. finally:
  350. if worktrees is not None:
  351. worktrees.stop()
  352. sys.exit(ec)