Blob Blame Raw
#!/usr/bin/env python

"""
 (c) 2015-2017 - Copyright Red Hat Inc

 Authors:
   Pierre-Yves Chibon <pingou@pingoured.fr>


Streaming server for pagure's eventsource feature
This server takes messages sent to redis and publish them at the specified
endpoint

To test, run this script and in another terminal
nc localhost 8080
  HELLO

  GET /test/issue/26?foo=bar HTTP/1.1

"""

from __future__ import unicode_literals, absolute_import

import logging
import os


import redis
import trololio

from six.moves.urllib.parse import urlparse

log = logging.getLogger(__name__)


if 'PAGURE_CONFIG' not in os.environ \
        and os.path.exists('/etc/pagure/pagure.cfg'):
    print('Using configuration file `/etc/pagure/pagure.cfg`')
    os.environ['PAGURE_CONFIG'] = '/etc/pagure/pagure.cfg'


import pagure  # noqa: E402
import pagure.lib.model_base  # noqa: E402
import pagure.lib.query  # noqa: E402
from pagure.exceptions import PagureException, PagureEvException  # noqa: E402

SERVER = None
SESSION = None
POOL = redis.ConnectionPool(
    host=pagure.config.config['REDIS_HOST'],
    port=pagure.config.config['REDIS_PORT'],
    db=pagure.config.config['REDIS_DB'])


def _get_session():
    global SESSION
    if SESSION is None:
        print(pagure.config.config['DB_URL'])
        SESSION = pagure.lib.model_base.create_session(
            pagure.config.config['DB_URL'])

    return SESSION


def _get_issue(repo, objid):
    """Get a Ticket (issue) instance for a given repo (Project) and
    objid (issue number).
    """
    issue = None
    if not repo.settings.get('issue_tracker', True):
        raise PagureEvException("No issue tracker found for this project")

    session = _get_session()
    issue = pagure.lib.query.search_issues(session, repo, issueid=objid)

    if issue is None or issue.project != repo:
        raise PagureEvException("Issue '%s' not found" % objid)

    if issue.private:
        # TODO: find a way to do auth
        raise PagureEvException(
            "This issue is private and you are not allowed to view it")

    return issue


def _get_pull_request(repo, objid):
    """Get a PullRequest instance for a given repo (Project) and objid
    (request number).
    """
    if not repo.settings.get('pull_requests', True):
        raise PagureEvException(
            "No pull-request tracker found for this project")

    session = _get_session()
    request = pagure.lib.query.search_pull_requests(
        session, project_id=repo.id, requestid=objid)

    if request is None or request.project != repo:
        raise PagureEvException("Pull-Request '%s' not found" % objid)

    return request


# Dict representing known object types that we handle requests for,
# and the bound functions for getting an object instance from the
# parsed path data. Has to come after the functions it binds
OBJECTS = {
    'issue': _get_issue,
    'pull-request': _get_pull_request
}


def get_obj_from_path(path):
    """ Return the Ticket or Request object based on the path provided.
    """
    (username, namespace, reponame, objtype, objid) = pagure.utils.parse_path(
        path)
    session = _get_session()
    repo = pagure.lib.query.get_authorized_project(
            session, reponame, user=username, namespace=namespace)

    if repo is None:
        raise PagureEvException("Project '%s' not found" % reponame)

    # find the appropriate object getter function from OBJECTS
    try:
        getfunc = OBJECTS[objtype]
    except KeyError:
        raise PagureEvException("Invalid object provided: '%s'" % objtype)

    return getfunc(repo, objid)


@trololio.coroutine
def handle_client(client_reader, client_writer):
    data = None
    while True:
        # give client a chance to respond, timeout after 10 seconds
        line = yield trololio.From(trololio.asyncio.wait_for(
            client_reader.readline(),
            timeout=10.0))
        if not line.decode().strip():
            break
        line = line.decode().rstrip()
        if data is None:
            data = line

    if data is None:
        log.warning("Expected ticket uid, received None")
        return

    data = data.decode().rstrip().split()
    log.info("Received %s", data)
    if not data:
        log.warning("No URL provided: %s" % data)
        return

    if '/' not in data[1]:
        log.warning("Invalid URL provided: %s" % data[1])
        return

    url = urlparse(data[1])

    try:
        obj = get_obj_from_path(url.path)
    except PagureException as err:
        log.warning(err.message)
        return

    origin = pagure.config.config.get('APP_URL')
    if origin.endswith('/'):
        origin = origin[:-1]

    client_writer.write((
        "HTTP/1.0 200 OK\n"
        "Content-Type: text/event-stream\n"
        "Cache: nocache\n"
        "Connection: keep-alive\n"
        "Access-Control-Allow-Origin: %s\n\n" % origin
    ).encode())


    conn = redis.Redis(connection_pool=POOL)
    subscriber = conn.pubsub(ignore_subscribe_messages=True)

    try:
        subscriber.subscribe('pagure.%s' % obj.uid)

        # Inside a while loop, wait for incoming events.
        oncall = 0
        while True:
            msg = subscriber.get_message()
            if msg is None:
                # Send a ping to see if the client is still alive
                if oncall >= 5:
                    # Only send a ping once every 5 seconds
                    client_writer.write(('event: ping\n\n').encode())
                    oncall = 0
                oncall += 1
                yield trololio.From(client_writer.drain())
                yield trololio.From(trololio.asyncio.sleep(1))
            else:
                log.info("Sending %s", msg['data'])
                client_writer.write(('data: %s\n\n' % msg['data']).encode())
                yield trololio.From(client_writer.drain())

    except OSError:
        log.info("Client closed connection")
    except trololio.ConnectionResetError as err:
        log.exception("ERROR: ConnectionResetError in handle_client")
    except Exception as err:
        log.exception("ERROR: Exception in handle_client")
        log.info(type(err))
    finally:
        # Wathever happens, close the connection.
        log.info("Client left. Goodbye!")
        subscriber.close()
        client_writer.close()


@trololio.coroutine
def stats(client_reader, client_writer):

    try:
        log.info('Clients: %s', SERVER.active_count)
        client_writer.write((
            "HTTP/1.0 200 OK\n"
            "Cache: nocache\n\n"
        ).encode())
        client_writer.write(('data: %s\n\n' % SERVER.active_count).encode())
        yield trololio.From(client_writer.drain())

    except trololio.ConnectionResetError as err:
        log.info(err)
    finally:
        client_writer.close()
    return


def main():
    global SERVER
    _get_session()

    try:
        loop = trololio.asyncio.get_event_loop()
        coro = trololio.asyncio.start_server(
            handle_client,
            host=None,
            port=pagure.config.config['EVENTSOURCE_PORT'],
            loop=loop)
        SERVER = loop.run_until_complete(coro)
        log.info(
            'Serving server at {}'.format(SERVER.sockets[0].getsockname()))
        if pagure.config.config.get('EV_STATS_PORT'):
            stats_coro = trololio.asyncio.start_server(
                stats,
                host=None,
                port=pagure.config.config.get('EV_STATS_PORT'),
                loop=loop)
            stats_server = loop.run_until_complete(stats_coro)
            log.info('Serving stats  at {}'.format(
                stats_server.sockets[0].getsockname()))
        loop.run_forever()
    except KeyboardInterrupt:
        pass
    except trololio.ConnectionResetError as err:
        log.exception("ERROR: ConnectionResetError in main")
    except Exception:
        log.exception("ERROR: Exception in main")
    finally:
        # Close the server
        SERVER.close()
        if pagure.config.config.get('EV_STATS_PORT'):
            stats_server.close()
        log.info("End Connection")
        loop.run_until_complete(SERVER.wait_closed())
        loop.close()
        log.info("End")


if __name__ == '__main__':
    log = logging.getLogger("")
    formatter = logging.Formatter(
        "%(asctime)s %(levelname)s [%(module)s:%(lineno)d] %(message)s")

    # setup console logging
    log.setLevel(logging.DEBUG)
    ch = logging.StreamHandler()
    ch.setLevel(logging.DEBUG)

    aslog = logging.getLogger("asyncio")
    aslog.setLevel(logging.DEBUG)

    ch.setFormatter(formatter)
    log.addHandler(ch)
    main()