diffido.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """Diffido - because the F5 key is a terrible thing to waste.
  4. Copyright 2018 Davide Alberani <da@erlug.linux.it>
  5. Licensed under the Apache License, Version 2.0 (the "License");
  6. you may not use this file except in compliance with the License.
  7. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
  8. Unless required by applicable law or agreed to in writing, software
  9. distributed under the License is distributed on an "AS IS" BASIS,
  10. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  11. See the License for the specific language governing permissions and
  12. limitations under the License.
  13. """
  14. import os
  15. import re
  16. import io
  17. import json
  18. import shutil
  19. import urllib
  20. import smtplib
  21. from email.mime.text import MIMEText
  22. import logging
  23. import datetime
  24. import requests
  25. import subprocess
  26. import multiprocessing
  27. from lxml import etree
  28. from xml.etree import ElementTree
  29. from tornado.ioloop import IOLoop
  30. from apscheduler.triggers.cron import CronTrigger
  31. from apscheduler.schedulers.tornado import TornadoScheduler
  32. from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
  33. import tornado.httpserver
  34. import tornado.ioloop
  35. import tornado.options
  36. from tornado.options import define, options
  37. import tornado.web
  38. from tornado import gen, escape
  39. JOBS_STORE = 'sqlite:///conf/jobs.db'
  40. API_VERSION = '1.0'
  41. SCHEDULES_FILE = 'conf/schedules.json'
  42. DEFAULT_CONF = 'conf/diffido.conf'
  43. EMAIL_FROM = 'diffido@localhost'
  44. GIT_CMD = 'git'
  45. re_commit = re.compile(r'^(?P<id>[0-9a-f]{40}) (?P<message>.*)\n(?: .* '
  46. '(?P<insertions>\d+) insertion.* (?P<deletions>\d+) deletion.*$)?', re.M)
  47. re_insertion = re.compile(r'(\d+) insertion')
  48. re_deletion = re.compile(r'(\d+) deletion')
  49. logger = logging.getLogger()
  50. logger.setLevel(logging.INFO)
  51. def read_schedules():
  52. """Return the schedules configuration.
  53. :returns: dictionary from the JSON object in conf/schedules.json
  54. :rtype: dict"""
  55. if not os.path.isfile(SCHEDULES_FILE):
  56. return {'schedules': {}}
  57. try:
  58. with open(SCHEDULES_FILE, 'r') as fd:
  59. return json.loads(fd.read())
  60. except Exception as e:
  61. logger.error('unable to read %s: %s' % (SCHEDULES_FILE, e))
  62. return {'schedules': {}}
  63. def write_schedules(schedules):
  64. """Write the schedules configuration.
  65. :param schedules: the schedules to save
  66. :type schedules: dict
  67. :returns: True in case of success
  68. :rtype: bool"""
  69. try:
  70. with open(SCHEDULES_FILE, 'w') as fd:
  71. fd.write(json.dumps(schedules, indent=2))
  72. except Exception as e:
  73. logger.error('unable to write %s: %s' % (SCHEDULES_FILE, e))
  74. return False
  75. return True
  76. def next_id(schedules):
  77. """Return the next available integer (as a string) in the list of schedules keys (do not fills holes)
  78. :param schedules: the schedules
  79. :type schedules: dict
  80. :returns: the ID of the next schedule
  81. :rtype: str"""
  82. ids = schedules.get('schedules', {}).keys()
  83. if not ids:
  84. return '1'
  85. return str(max([int(i) for i in ids]) + 1)
  86. def get_schedule(id_, add_id=True):
  87. """Return information about a single schedule
  88. :param id_: ID of the schedule
  89. :type id_: str
  90. :param add_id: if True, add the ID in the dictionary
  91. :type add_id: bool
  92. :returns: the schedule
  93. :rtype: dict"""
  94. try:
  95. schedules = read_schedules()
  96. except Exception:
  97. return {}
  98. data = schedules.get('schedules', {}).get(id_, {})
  99. if add_id:
  100. data['id'] = str(id_)
  101. return data
  102. def select_xpath(content, xpath):
  103. """Select a portion of a HTML document
  104. :param content: the content of the document
  105. :type content: str
  106. :param xpath: the XPath selector
  107. :type xpath: str
  108. :returns: the selected document
  109. :rtype: str"""
  110. fd = io.StringIO(content)
  111. tree = etree.parse(fd)
  112. elems = tree.xpath(xpath)
  113. if not elems:
  114. return content
  115. selected_content = []
  116. for elem in elems:
  117. selected_content.append(''.join([elem.text] + [ElementTree.tostring(e).decode('utf-8', 'replace')
  118. for e in elem.getchildren()]))
  119. content = ''.join(selected_content)
  120. return content
  121. def run_job(id_=None, *args, **kwargs):
  122. """Run a job
  123. :param id_: ID of the schedule to run
  124. :type id_: str
  125. :param args: positional arguments
  126. :type args: tuple
  127. :param kwargs: named arguments
  128. :type kwargs: dict
  129. :returns: True in case of success
  130. :rtype: bool"""
  131. schedule = get_schedule(id_, add_id=False)
  132. url = schedule.get('url')
  133. if not url:
  134. return False
  135. logger.debug('running job id:%s title:%s url: %s' % (id_, schedule.get('title', ''), url))
  136. req = requests.get(url, allow_redirects=True, timeout=(30.10, 240))
  137. content = req.text
  138. xpath = schedule.get('xpath')
  139. if xpath:
  140. try:
  141. content = select_xpath(content, xpath)
  142. except Exception as e:
  143. logger.warn('unable to extract XPath %s: %s' % (xpath, e))
  144. req_path = urllib.parse.urlparse(req.url).path
  145. base_name = os.path.basename(req_path) or 'index.html'
  146. def _commit(id_, filename, content, queue):
  147. os.chdir('storage/%s' % id_)
  148. current_lines = 0
  149. if os.path.isfile(filename):
  150. with open(filename, 'r') as fd:
  151. for line in fd:
  152. current_lines += 1
  153. with open(filename, 'w') as fd:
  154. fd.write(content)
  155. p = subprocess.Popen([GIT_CMD, 'add', filename])
  156. p.communicate()
  157. p = subprocess.Popen([GIT_CMD, 'commit', '-m', '%s' % datetime.datetime.utcnow(), '--allow-empty'],
  158. stdout=subprocess.PIPE)
  159. stdout, _ = p.communicate()
  160. stdout = stdout.decode('utf-8')
  161. insert = re_insertion.findall(stdout)
  162. if insert:
  163. insert = int(insert[0])
  164. else:
  165. insert = 0
  166. delete = re_deletion.findall(stdout)
  167. if delete:
  168. delete = int(delete[0])
  169. else:
  170. delete = 0
  171. queue.put({'insertions': insert, 'deletions': delete, 'previous_lines': current_lines,
  172. 'changes': max(insert, delete)})
  173. queue = multiprocessing.Queue()
  174. p = multiprocessing.Process(target=_commit, args=(id_, base_name, content, queue))
  175. p.start()
  176. res = queue.get()
  177. p.join()
  178. email = schedule.get('email')
  179. if not email:
  180. return True
  181. changes = res.get('changes')
  182. if not changes:
  183. return True
  184. min_change = schedule.get('minimum_change')
  185. previous_lines = res.get('previous_lines')
  186. if min_change and previous_lines:
  187. min_change = float(min_change)
  188. change_fraction = res.get('changes') / previous_lines
  189. if change_fraction < min_change:
  190. return True
  191. # send notification
  192. diff = get_diff(id_).get('diff')
  193. if not diff:
  194. return
  195. send_email(to=email, subject='%s page changed' % schedule.get('title'),
  196. body='changes:\n\n%s' % diff)
  197. def safe_run_job(id_=None, *args, **kwargs):
  198. """Safely run a job, catching all the exceptions
  199. :param id_: ID of the schedule to run
  200. :type id_: str
  201. :param args: positional arguments
  202. :type args: tuple
  203. :param kwargs: named arguments
  204. :type kwargs: dict
  205. :returns: True in case of success
  206. :rtype: bool"""
  207. try:
  208. run_job(id_, *args, **kwargs)
  209. except Exception as e:
  210. send_email('error executing job %s: %s' % (id_, e))
  211. def send_email(to, subject='diffido', body='', from_=None):
  212. """Send an email
  213. :param to: destination address
  214. :type to: str
  215. :param subject: email subject
  216. :type subject: str
  217. :param body: body of the email
  218. :type body: str
  219. :param from_: sender address
  220. :type from_: str
  221. :returns: True in case of success
  222. :rtype: bool"""
  223. msg = MIMEText(body)
  224. msg['Subject'] = subject
  225. msg['From'] = from_ or EMAIL_FROM
  226. msg['To'] = to
  227. with smtplib.SMTP('localhost') as s:
  228. s.send_message(msg)
  229. return True
  230. def get_history(id_):
  231. """Read the history of a schedule
  232. :param id_: ID of the schedule
  233. :type id_: str
  234. :returns: information about the schedule and its history
  235. :rtype: dict"""
  236. def _history(id_, queue):
  237. os.chdir('storage/%s' % id_)
  238. p = subprocess.Popen([GIT_CMD, 'log', '--pretty=oneline', '--shortstat'], stdout=subprocess.PIPE)
  239. stdout, _ = p.communicate()
  240. queue.put(stdout)
  241. queue = multiprocessing.Queue()
  242. p = multiprocessing.Process(target=_history, args=(id_, queue))
  243. p.start()
  244. res = queue.get().decode('utf-8')
  245. p.join()
  246. history = []
  247. for match in re_commit.finditer(res):
  248. info = match.groupdict()
  249. info['insertions'] = int(info['insertions'] or 0)
  250. info['deletions'] = int(info['deletions'] or 0)
  251. info['changes'] = max(info['insertions'], info['deletions'])
  252. history.append(info)
  253. lastid = None
  254. if history and 'id' in history[0]:
  255. lastid = history[0]['id']
  256. for idx, item in enumerate(history):
  257. item['seq'] = idx + 1
  258. schedule = get_schedule(id_)
  259. return {'history': history, 'lastid': lastid, 'schedule': schedule}
  260. def get_diff(id_, commit_id='HEAD', old_commit_id=None):
  261. """Return the diff between commits of a schedule
  262. :param id_: ID of the schedule
  263. :type id_: str
  264. :param commit_id: the most recent commit ID; HEAD by default
  265. :type commit_id: str
  266. :param old_commit_id: the older commit ID; if None, the previous commit is used
  267. :type old_commit_id: str
  268. :returns: information about the schedule and the diff between commits
  269. :rtype: dict"""
  270. def _history(id_, commit_id, old_commit_id, queue):
  271. os.chdir('storage/%s' % id_)
  272. p = subprocess.Popen([GIT_CMD, 'diff', old_commit_id or '%s~' % commit_id, commit_id],
  273. stdout=subprocess.PIPE)
  274. stdout, _ = p.communicate()
  275. queue.put(stdout)
  276. queue = multiprocessing.Queue()
  277. p = multiprocessing.Process(target=_history, args=(id_, commit_id, old_commit_id, queue))
  278. p.start()
  279. res = queue.get().decode('utf-8')
  280. p.join()
  281. schedule = get_schedule(id_)
  282. return {'diff': res, 'schedule': schedule}
  283. def scheduler_update(scheduler, id_):
  284. """Update a scheduler job, using information from the JSON object
  285. :param scheduler: the TornadoScheduler instance to modify
  286. :type scheduler: TornadoScheduler
  287. :param id_: ID of the schedule that must be updated
  288. :type id_: str
  289. :returns: True in case of success
  290. :rtype: bool"""
  291. schedule = get_schedule(id_, add_id=False)
  292. if not schedule:
  293. logger.warn('unable to update empty schedule %s' % id_)
  294. return False
  295. trigger = schedule.get('trigger')
  296. if trigger not in ('interval', 'cron'):
  297. logger.warn('unable to update empty schedule %s: trigger not in ("cron", "interval")' % id_)
  298. return False
  299. args = {}
  300. if trigger == 'interval':
  301. args['trigger'] = 'interval'
  302. for unit in 'weeks', 'days', 'hours', 'minutes', 'seconds':
  303. if 'interval_%s' % unit not in schedule:
  304. continue
  305. try:
  306. args[unit] = int(schedule['interval_%s' % unit])
  307. except Exception:
  308. logger.warn('invalid argument on schedule %s: %s parameter %s is not an integer' %
  309. (id_, 'interval_%s' % unit, schedule['interval_%s' % unit]))
  310. elif trigger == 'cron':
  311. try:
  312. cron_trigger = CronTrigger.from_crontab(schedule['cron_crontab'])
  313. args['trigger'] = cron_trigger
  314. except Exception:
  315. logger.warn('invalid argument on schedule %s: cron_tab parameter %s is not a valid crontab' %
  316. (id_, schedule.get('cron_crontab')))
  317. git_create_repo(id_)
  318. try:
  319. scheduler.add_job(safe_run_job, id=id_, replace_existing=True, kwargs={'id_': id_}, **args)
  320. except Exception as e:
  321. logger.warn('unable to update job %s: %s' % (id_, e))
  322. return False
  323. return True
  324. def scheduler_delete(scheduler, id_):
  325. """Update a scheduler job, using information from the JSON object
  326. :param scheduler: the TornadoScheduler instance to modify
  327. :type scheduler: TornadoScheduler
  328. :param id_: ID of the schedule
  329. :type id_: str
  330. :returns: True in case of success
  331. :rtype: bool"""
  332. try:
  333. scheduler.remove_job(job_id=id_)
  334. except Exception as e:
  335. logger.warn('unable to delete job %s: %s' % (id_, e))
  336. return False
  337. return git_delete_repo(id_)
  338. def reset_from_schedules(scheduler):
  339. """"Reset all scheduler jobs, using information from the JSON object
  340. :param scheduler: the TornadoScheduler instance to modify
  341. :type scheduler: TornadoScheduler
  342. :returns: True in case of success
  343. :rtype: bool"""
  344. ret = False
  345. try:
  346. scheduler.remove_all_jobs()
  347. for key in read_schedules().get('schedules', {}).keys():
  348. ret |= scheduler_update(scheduler, id_=key)
  349. except Exception as e:
  350. logger.warn('unable to reset all jobs: %s' % e)
  351. return False
  352. return ret
  353. def git_create_repo(id_):
  354. """Create a Git repository
  355. :param id_: ID of the schedule
  356. :type id_: str
  357. :returns: True in case of success
  358. :rtype: bool"""
  359. repo_dir = 'storage/%s' % id_
  360. if os.path.isdir(repo_dir):
  361. return True
  362. p = subprocess.Popen([GIT_CMD, 'init', repo_dir])
  363. p.communicate()
  364. return p.returncode == 0
  365. def git_delete_repo(id_):
  366. """Delete a Git repository
  367. :param id_: ID of the schedule
  368. :type id_: str
  369. :returns: True in case of success
  370. :rtype: bool"""
  371. repo_dir = 'storage/%s' % id_
  372. if not os.path.isdir(repo_dir):
  373. return False
  374. try:
  375. shutil.rmtree(repo_dir)
  376. except Exception as e:
  377. logger.warn('unable to delete Git repository %s: %s' % (id_, e))
  378. return False
  379. return True
  380. class DiffidoBaseException(Exception):
  381. """Base class for diffido custom exceptions.
  382. :param message: text message
  383. :type message: str
  384. :param status: numeric http status code
  385. :type status: int"""
  386. def __init__(self, message, status=400):
  387. super(DiffidoBaseException, self).__init__(message)
  388. self.message = message
  389. self.status = status
  390. class BaseHandler(tornado.web.RequestHandler):
  391. """Base class for request handlers."""
  392. # A property to access the first value of each argument.
  393. arguments = property(lambda self: dict([(k, v[0].decode('utf-8'))
  394. for k, v in self.request.arguments.items()]))
  395. @property
  396. def clean_body(self):
  397. """Return a clean dictionary from a JSON body, suitable for a query on MongoDB.
  398. :returns: a clean copy of the body arguments
  399. :rtype: dict"""
  400. return escape.json_decode(self.request.body or '{}')
  401. def write_error(self, status_code, **kwargs):
  402. """Default error handler."""
  403. if isinstance(kwargs.get('exc_info', (None, None))[1], DiffidoBaseException):
  404. exc = kwargs['exc_info'][1]
  405. status_code = exc.status
  406. message = exc.message
  407. else:
  408. message = 'internal error'
  409. self.build_error(message, status=status_code)
  410. def initialize(self, **kwargs):
  411. """Add every passed (key, value) as attributes of the instance."""
  412. for key, value in kwargs.items():
  413. setattr(self, key, value)
  414. def build_error(self, message='', status=400):
  415. """Build and write an error message.
  416. :param message: textual message
  417. :type message: str
  418. :param status: HTTP status code
  419. :type status: int
  420. """
  421. self.set_status(status)
  422. self.write({'error': True, 'message': message})
  423. def build_success(self, message='', status=200):
  424. """Build and write a success message.
  425. :param message: textual message
  426. :type message: str
  427. :param status: HTTP status code
  428. :type status: int
  429. """
  430. self.set_status(status)
  431. self.write({'error': False, 'message': message})
  432. class SchedulesHandler(BaseHandler):
  433. """Schedules handler."""
  434. @gen.coroutine
  435. def get(self, id_=None, *args, **kwargs):
  436. """Get a schedule."""
  437. if id_ is not None:
  438. return self.write({'schedule': get_schedule(id_)})
  439. schedules = read_schedules()
  440. self.write(schedules)
  441. @gen.coroutine
  442. def put(self, id_=None, *args, **kwargs):
  443. """Update a schedule."""
  444. if id_ is None:
  445. return self.build_error(message='update action requires an ID')
  446. data = self.clean_body
  447. schedules = read_schedules()
  448. if id_ not in schedules.get('schedules', {}):
  449. return self.build_error(message='schedule %s not found' % id_)
  450. schedules['schedules'][id_] = data
  451. write_schedules(schedules)
  452. scheduler_update(scheduler=self.scheduler, id_=id_)
  453. self.write(get_schedule(id_=id_))
  454. @gen.coroutine
  455. def post(self, *args, **kwargs):
  456. """Add a schedule."""
  457. data = self.clean_body
  458. schedules = read_schedules()
  459. id_ = next_id(schedules)
  460. schedules['schedules'][id_] = data
  461. write_schedules(schedules)
  462. scheduler_update(scheduler=self.scheduler, id_=id_)
  463. self.write(get_schedule(id_=id_))
  464. @gen.coroutine
  465. def delete(self, id_=None, *args, **kwargs):
  466. """Delete a schedule."""
  467. if id_ is None:
  468. return self.build_error(message='an ID must be specified')
  469. schedules = read_schedules()
  470. if id_ in schedules.get('schedules', {}):
  471. del schedules['schedules'][id_]
  472. write_schedules(schedules)
  473. scheduler_delete(scheduler=self.scheduler, id_=id_)
  474. self.build_success(message='removed schedule %s' % id_)
  475. class ResetSchedulesHandler(BaseHandler):
  476. """Reset schedules handler."""
  477. @gen.coroutine
  478. def post(self, *args, **kwargs):
  479. reset_from_schedules(self.scheduler)
  480. class HistoryHandler(BaseHandler):
  481. """History handler."""
  482. @gen.coroutine
  483. def get(self, id_, *args, **kwargs):
  484. self.write(get_history(id_))
  485. class DiffHandler(BaseHandler):
  486. """Diff handler."""
  487. @gen.coroutine
  488. def get(self, id_, commit_id, old_commit_id=None, *args, **kwargs):
  489. self.write(get_diff(id_, commit_id, old_commit_id))
  490. class TemplateHandler(BaseHandler):
  491. """Handler for the template files in the / path."""
  492. @gen.coroutine
  493. def get(self, *args, **kwargs):
  494. """Get a template file."""
  495. page = 'index.html'
  496. if args and args[0]:
  497. page = args[0].strip('/')
  498. arguments = self.arguments
  499. self.render(page, **arguments)
  500. def serve():
  501. """Read configuration and start the server."""
  502. global EMAIL_FROM
  503. jobstores = {'default': SQLAlchemyJobStore(url=JOBS_STORE)}
  504. scheduler = TornadoScheduler(jobstores=jobstores)
  505. scheduler.start()
  506. define('port', default=3210, help='run on the given port', type=int)
  507. define('address', default='', help='bind the server at the given address', type=str)
  508. define('ssl_cert', default=os.path.join(os.path.dirname(__file__), 'ssl', 'diffido_cert.pem'),
  509. help='specify the SSL certificate to use for secure connections')
  510. define('ssl_key', default=os.path.join(os.path.dirname(__file__), 'ssl', 'diffido_key.pem'),
  511. help='specify the SSL private key to use for secure connections')
  512. define('admin-email', default='', help='email address of the site administrator', type=str)
  513. define('debug', default=False, help='run in debug mode')
  514. define('config', help='read configuration file',
  515. callback=lambda path: tornado.options.parse_config_file(path, final=False))
  516. if not options.config and os.path.isfile(DEFAULT_CONF):
  517. tornado.options.parse_config_file(DEFAULT_CONF, final=False)
  518. tornado.options.parse_command_line()
  519. if options.admin_email:
  520. EMAIL_FROM = options.admin_email
  521. if options.debug:
  522. logger.setLevel(logging.DEBUG)
  523. ssl_options = {}
  524. if os.path.isfile(options.ssl_key) and os.path.isfile(options.ssl_cert):
  525. ssl_options = dict(certfile=options.ssl_cert, keyfile=options.ssl_key)
  526. init_params = dict(listen_port=options.port, logger=logger, ssl_options=ssl_options,
  527. scheduler=scheduler)
  528. _reset_schedules_path = r'schedules/reset'
  529. _schedules_path = r'schedules/?(?P<id_>\d+)?'
  530. _history_path = r'history/?(?P<id_>\d+)'
  531. _diff_path = r'diff/(?P<id_>\d+)/(?P<commit_id>[0-9a-f]+)/?(?P<old_commit_id>[0-9a-f]+)?/?'
  532. application = tornado.web.Application([
  533. (r'/api/%s' % _reset_schedules_path, ResetSchedulesHandler, init_params),
  534. (r'/api/v%s/%s' % (API_VERSION, _reset_schedules_path), ResetSchedulesHandler, init_params),
  535. (r'/api/%s' % _schedules_path, SchedulesHandler, init_params),
  536. (r'/api/v%s/%s' % (API_VERSION, _schedules_path), SchedulesHandler, init_params),
  537. (r'/api/%s' % _history_path, HistoryHandler, init_params),
  538. (r'/api/v%s/%s' % (API_VERSION, _history_path), HistoryHandler, init_params),
  539. (r'/api/%s' % _diff_path, DiffHandler, init_params),
  540. (r'/api/v%s/%s' % (API_VERSION, _diff_path), DiffHandler, init_params),
  541. (r'/?(.*)', TemplateHandler, init_params),
  542. ],
  543. static_path=os.path.join(os.path.dirname(__file__), 'dist/static'),
  544. template_path=os.path.join(os.path.dirname(__file__), 'dist/'),
  545. debug=options.debug)
  546. http_server = tornado.httpserver.HTTPServer(application, ssl_options=ssl_options or None)
  547. logger.info('Start serving on %s://%s:%d', 'https' if ssl_options else 'http',
  548. options.address if options.address else '127.0.0.1',
  549. options.port)
  550. http_server.listen(options.port, options.address)
  551. try:
  552. IOLoop.instance().start()
  553. except (KeyboardInterrupt, SystemExit):
  554. pass
  555. if __name__ == '__main__':
  556. serve()