#!/usr/bin/python

# Copyright (c) 2016 IBM Corp.
#
# This module is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This software is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this software.  If not, see <http://www.gnu.org/licenses/>.

import glob
import os
import sys
import select
import socket
import subprocess
import threading
import time

# This is the version we report to the zuul_stream callback.  It is
# expected that this (zuul_console) process can be long-lived, so if
# there are updates this ensures a later streaming callback can still
# talk to us.
ZUUL_CONSOLE_PROTO_VERSION = 1
# This is the template for the file name of the log-file written out
# by the command.py override command in the executor's Ansible
# install.
LOG_STREAM_FILE = '/tmp/console-{log_uuid}.log'
LOG_STREAM_PORT = 19885


def daemonize():
    # A really basic daemonize method that should work well enough for
    # now in this circumstance. Based on the public domain code at:
    # http://web.archive.org/web/20131017130434/http://www.jejik.com/articles/2007/02/a_simple_unix_linux_daemon_in_python/

    pid = os.fork()
    if pid > 0:
        return True

    os.chdir('/')
    os.setsid()
    os.umask(0)

    pid = os.fork()
    if pid > 0:
        sys.exit(0)

    sys.stdout.flush()
    sys.stderr.flush()
    i = open('/dev/null', 'r')
    o = open('/dev/null', 'a+')
    e = open('/dev/null', 'ab+', 0)
    os.dup2(i.fileno(), sys.stdin.fileno())
    os.dup2(o.fileno(), sys.stdout.fileno())
    os.dup2(e.fileno(), sys.stderr.fileno())
    return False


class Console(object):
    def __init__(self, path):
        self.path = path
        self.file = open(path, 'rb')
        self.stat = os.stat(path)
        self.size = self.stat.st_size


class Server(object):

    MAX_REQUEST_LEN = 1024
    REQUEST_TIMEOUT = 10

    def __init__(self, path, port):
        self.path = path

        s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
        s.setsockopt(socket.SOL_SOCKET,
                     socket.SO_REUSEADDR, 1)
        s.bind(('::', port))
        s.listen(1)

        self.socket = s

    def accept(self):
        conn, addr = self.socket.accept()
        return conn

    def run(self):
        while True:
            conn = self.accept()
            t = threading.Thread(target=self.handleOneConnection, args=(conn,))
            t.daemon = True
            t.start()

    def chunkConsole(self, conn, log_uuid):
        try:
            console = Console(self.path.format(log_uuid=log_uuid))
        except Exception:
            return
        while True:
            chunk = console.file.read(4096)
            if not chunk:
                break
            conn.send(chunk)
        return console

    def followConsole(self, console, conn):
        while True:
            # As long as we have unread data, keep reading/sending
            while True:
                chunk = console.file.read(4096)
                if chunk:
                    conn.send(chunk)
                else:
                    break

            # At this point, we are waiting for more data to be written
            time.sleep(0.5)

            # Check to see if the remote end has sent any data, if so,
            # discard
            r, w, e = select.select([conn], [], [conn], 0)
            if conn in e:
                return False
            if conn in r:
                ret = conn.recv(1024)
                # Discard anything read, if input is eof, it has
                # disconnected.
                if not ret:
                    return False

            # See if the file has been truncated
            try:
                st = os.stat(console.path)
                if (st.st_ino != console.stat.st_ino or
                    st.st_size < console.size):
                    return True
            except Exception:
                return True
            console.size = st.st_size

    def get_command(self, conn):
        poll = select.poll()
        bitmask = (select.POLLIN | select.POLLERR |
                   select.POLLHUP | select.POLLNVAL)
        poll.register(conn, bitmask)
        buff = b''
        ret = None
        start = time.time()
        while True:
            elapsed = time.time() - start
            timeout = max(self.REQUEST_TIMEOUT - elapsed, 0)
            if not timeout:
                raise Exception("Timeout while waiting for input")
            for fd, event in poll.poll(timeout):
                if event & select.POLLIN:
                    chunk = conn.recv(self.MAX_REQUEST_LEN)
                    if not chunk:
                        raise Exception("Remote side closed connection")
                    buff += chunk
                else:
                    raise Exception("Received error event")
            if len(buff) >= self.MAX_REQUEST_LEN:
                raise Exception("Request too long")
            try:
                ret = buff.decode('utf-8')
                x = ret.find('\n')
                if x > 0:
                    return ret[:x].strip()
            except UnicodeDecodeError:
                pass

    def _clean_uuid(self, log_uuid):
        # use path split to make use the input isn't trying to be clever
        # and construct some path like /tmp/console-/../../something
        return os.path.split(log_uuid)[-1]

    def handleOneConnection(self, conn):
        # V1 protocol
        # -----------
        #  v:<ver>    get version number, <ver> is remote version
        #  s:<uuid>   send logs for <uuid>
        #  f:<uuid>   finalise/cleanup <uuid>
        while True:
            command = self.get_command(conn)
            if command.startswith('v:'):
                # NOTE(ianw) : remote sends its version.  We currently
                # don't have anything to do with this value, so ignore
                # for now.
                cmd = '%s\n' % (ZUUL_CONSOLE_PROTO_VERSION)
                conn.send(cmd.encode('utf-8'))
                continue
            elif command.startswith('f:'):
                log_uuid = self._clean_uuid(command[2:])
                try:
                    os.unlink(self.path.format(log_uuid=log_uuid))
                except Exception:
                    # something might have cleaned /tmp
                    pass
                continue
            elif command.startswith('s:'):
                log_uuid = self._clean_uuid(command[2:])
                break
            else:
                # NOTE(ianw): 2022-07-21 In releases < 6.3.0 the streaming
                # side would just send a raw uuid and nothing else; so by
                # default assume that is what is coming in here.  We can
                # remove this fallback when we decide it is no longer
                # necessary.
                log_uuid = self._clean_uuid(command)
                break

        # FIXME: this won't notice disconnects until it tries to send
        console = None
        try:
            while True:
                if console is not None:
                    try:
                        console.file.close()
                    except Exception:
                        pass
                while True:
                    console = self.chunkConsole(conn, log_uuid)
                    if console:
                        break
                    conn.send(b'[Zuul] Log not found\n')
                    time.sleep(0.5)
                while True:
                    if self.followConsole(console, conn):
                        break
                    else:
                        return
        finally:
            try:
                conn.close()
            except Exception:
                pass


def get_inode(port_number=19885):
    for netfile in ('/proc/net/tcp6', '/proc/net/tcp'):
        if not os.path.exists(netfile):
            continue
        with open(netfile) as f:
            # discard header line
            f.readline()
            for line in f:
                # sl local_address rem_address st tx_queue:rx_queue tr:tm->when
                # retrnsmt   uid  timeout inode
                fields = line.split()
                # Format is localaddr:localport in hex
                port = int(fields[1].split(':')[1], base=16)
                if port == port_number:
                    return fields[9]


def get_pid_from_inode(inode):
    my_euid = os.geteuid()
    exceptions = []
    for d in os.listdir('/proc'):
        try:
            try:
                int(d)
            except Exception:
                continue
            d_abs_path = os.path.join('/proc', d)
            if os.stat(d_abs_path).st_uid != my_euid:
                continue
            fd_dir = os.path.join(d_abs_path, 'fd')
            if os.path.exists(fd_dir):
                if os.stat(fd_dir).st_uid != my_euid:
                    continue
                for fd in os.listdir(fd_dir):
                    try:
                        fd_path = os.path.join(fd_dir, fd)
                        if os.path.islink(fd_path):
                            target = os.readlink(fd_path)
                            if '[' + inode + ']' in target:
                                return d, exceptions
                    except Exception as e:
                        exceptions.append(e)
        except Exception as e:
            exceptions.append(e)
    return None, exceptions


def test():
    s = Server(LOG_STREAM_FILE, LOG_STREAM_PORT)
    s.run()


def main():
    module = AnsibleModule(
        argument_spec=dict(
            path=dict(default=LOG_STREAM_FILE),
            port=dict(default=LOG_STREAM_PORT, type='int'),
            state=dict(default='present', choices=['absent', 'present']),
        )
    )

    p = module.params
    path = p['path']
    port = p['port']
    state = p['state']

    if state == 'present':
        if daemonize():
            module.exit_json()

        s = Server(path, port)
        s.run()
    else:
        pid = None
        exceptions = []
        inode = get_inode(port)
        if not inode:
            module.fail_json(
                msg="Could not find inode for port",
                exceptions=[])

        pid, exceptions = get_pid_from_inode(inode)
        if not pid:
            except_strings = [str(e) for e in exceptions]
            module.fail_json(
                msg="Could not find zuul_console process for inode",
                exceptions=except_strings)

        try:
            subprocess.check_output(['kill', pid])
        except subprocess.CalledProcessError as e:
            module.fail_json(
                msg="Could not kill zuul_console pid",
                exceptions=[str(e)])

        for fn in glob.glob(LOG_STREAM_FILE.format(log_uuid='*')):
            try:
                os.unlink(fn)
            except Exception as e:
                module.fail_json(
                    msg="Could not remove logfile {fn}".format(fn=fn),
                    exceptions=[str(e)])

        module.exit_json()

from ansible.module_utils.basic import *  # noqa
from ansible.module_utils.basic import AnsibleModule

#
# To debug this, you can run it with arguments specified on the
# command-line in a json file. e.g.
#
# $ cat args.json
# {"ANSIBLE_MODULE_ARGS": {
#    "state": "present"
#  }
# }
#
# Then from a virtualenv with Ansible installed you can run
#
# $ ./ansible-env/bin/python ./zuul_console.py args.json
#
if __name__ == '__main__':
    main()
