#!/usr/bin/python2.7

# Copyright (c) 2014, 2016, Oracle and/or its affiliates. All rights reserved.
#
#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.

import ConfigParser
import contextlib
import errno
import fcntl
import os
import pwd
import socket
import subprocess
import sys
import tempfile
import time

import smf_include

import nova.conf

GTF = "/usr/bin/gtf"
SVCADM = "/usr/sbin/svcadm"
SVCCFG = "/usr/sbin/svccfg"
SVCPROP = "/usr/bin/svcprop"
VNCSERVER = "/usr/bin/vncserver"
XRANDR = "/usr/bin/xrandr"
NOVACFG = "/etc/nova/nova.conf"
XSTARTUPHDR = "# WARNING: THIS FILE GENERATED BY SMF.\n" + \
              "#   DO NOT EDIT THIS FILE.  EDITS WILL BE LOST.\n"
XRESOURCES = "[[ -f ~/.Xresources ]] && /usr/bin/xrdb -merge ~/.Xresources\n"
XTERM = "/usr/bin/xterm"
# Borderless, Monospsce font, point size 14, white foreground on black
# background are reasonable defaults.
XTERMOPTS = ' -b 0 -fa Monospace -fs 14 -fg white -bg black -title ' + \
            '"Zone Console: $ZONENAME"'
XWININFO = "/usr/bin/xwininfo"

# Port ranges allocated for VNC and X11 sockets.
VNCPORT_START = 5900
VNCPORT_END = 5999
X11PORT_START = 6000

# Enclose command in comments to prevent xterm consuming zlogin opts
ZLOGINOPTS = ' -e "/usr/bin/pfexec /usr/sbin/zlogin -C -E $ZONENAME"\n'
XSTARTUP = XSTARTUPHDR + XRESOURCES + XTERM + XTERMOPTS + ZLOGINOPTS

CONF = nova.conf.CONF


def start():
    fmri = os.environ['SMF_FMRI']
    # This is meant to be an on-demand service.
    # Determine if nova-compute requested enablement of this instance.
    # Exit with SMF_EXIT_TEMP_DISABLE if not true.
    cmd = [SVCPROP, '-p', 'vnc/nova-enabled', fmri]
    svcprop = subprocess.Popen(cmd, stdout=subprocess.PIPE,
                               stderr=subprocess.PIPE)
    out, err = svcprop.communicate()
    retcode = svcprop.wait()
    if retcode != 0:
        print "Error reading 'vnc/nova-enabled' property: " + err
        return smf_include.SMF_EXIT_ERR_FATAL
    enabled = out.strip() == 'true'
    if not enabled:
        smf_include.smf_method_exit(
            smf_include.SMF_EXIT_TEMP_DISABLE,
            "nova_enabled",
            "nova-compute starts this service on demand")

    check_vncserver()
    homedir = os.environ.get('HOME')
    if not homedir:
        homedir = pwd.getpwuid(os.getuid()).pw_dir
        os.putenv("HOME", homedir)
    set_xstartup(homedir)
    display = None
    vncport = None

    try:
        zonename = fmri.rsplit(':', 1)[1]
        os.putenv("ZONENAME", zonename)
        desktop_name = zonename + ' console'
        novacfg = ConfigParser.RawConfigParser()
        novacfg.readfp(open(NOVACFG))
        try:
            vnc_listenip = novacfg.get("vnc", "vncserver_listen")
        except ConfigParser.NoOptionError:
            vnc_listenip = CONF.vnc.vncserver_listen

        with lock_available_port(vnc_listenip, VNCPORT_START, VNCPORT_END,
                                 homedir) as n:
            # NOTE: 'geometry' is that which matches the size of standard
            # 80 character undecorated xterm window using font style specified
            # in XTERMOPTS. The geometry doesn't matter too much because the
            # display will be resized using xrandr once the xterm geometry is
            # established.
            display = ":%d" % n
            cmd = [VNCSERVER, display, "-name", desktop_name,
                   "-SecurityTypes=None", "-geometry", "964x580",
                   "-interface", vnc_listenip, "-autokill"]

            vncport = VNCPORT_START + n
            x11port = X11PORT_START + n
            print "Using VNC server port: " + str(vncport)
            print "Using X11 server port: %d, display %s" % (x11port, display)
            vnc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
                                   stderr=subprocess.PIPE, env=None)
            out, err = vnc.communicate()
            vncret = vnc.wait()
        if vncret != 0:
            print "Error starting VNC server: " + err
            return smf_include.SMF_EXIT_ERR_FATAL
    except Exception as e:
        print e
        return smf_include.SMF_EXIT_ERR_FATAL

    # set SMF instance port num prop
    cmd = [SVCCFG, '-s', fmri, 'setprop', 'vnc/port', '=', 'integer:',
           str(vncport)]

    svccfg = subprocess.Popen(cmd, stdout=subprocess.PIPE,
                              stderr=subprocess.PIPE)
    out, err = svccfg.communicate()
    retcode = svccfg.wait()
    if retcode != 0:
        print "Error updating 'vnc/port' property: " + err
        return smf_include.SMF_EXIT_ERR_FATAL
    resize_xserver(display, zonename)

    return smf_include.SMF_EXIT_OK


def stop():
    try:
        # first kill the SMF contract
        subprocess.check_call(["/usr/bin/pkill", "-c", sys.argv[2]])
    except subprocess.CalledProcessError as cpe:
        # 1 is returncode if no SMF contract processes were matched,
        # meaning they have already terminated.
        if cpe.returncode != 1:
                print "failed to kill the SMF contract: %s" % cpe
                return smf_include.SMF_EXIT_ERR_FATAL

    try:
        fmri = os.environ['SMF_FMRI']
        # reset port num prop to initial zero value
        cmd = [SVCCFG, '-s', fmri, 'setprop', 'vnc/port', '=', 'integer:',
               '0']
        svccfg = subprocess.Popen(cmd, stdout=subprocess.PIPE,
                                  stderr=subprocess.PIPE,)
        out, err = svccfg.communicate()
        retcode = svccfg.wait()
        if retcode != 0:
            print "Error resetting 'vnc/port' property: " + err
            return smf_include.SMF_EXIT_ERR_FATAL
    except Exception as e:
        print e
        return smf_include.SMF_EXIT_ERR_FATAL


def check_vncserver():
    if not os.path.exists(VNCSERVER):
        print("VNC console service not available on this compute node. "
              "%s is missing. Run 'pkg install x11/server/xvnc'"
              % VNCSERVER)
        return smf_include.SMF_EXIT_ERR_FATAL
    if not os.path.exists(XTERM):
        print("VNC console service not available on this compute node. "
              "%s is missing. Run 'pkg install terminal/xterm'"
              % XTERM)
        return smf_include.SMF_EXIT_ERR_FATAL


def set_xstartup(homedir):
    vncdir = os.path.join(homedir, '.vnc')
    xstartup_path = os.path.join(vncdir, 'xstartup')

    try:
        os.mkdir(vncdir)
    except OSError as ose:
        if ose.errno != errno.EEXIST:
            raise

    # Always clobber xstartup
    # stemp tuple = [fd, path]
    stemp = tempfile.mkstemp(dir=vncdir)
    os.write(stemp[0], XSTARTUP)
    os.close(stemp[0])
    os.chmod(stemp[1], 0700)
    os.rename(stemp[1], xstartup_path)


def resize_xserver(display, zonename):
    """ Try to determine xterm window geometry and resize the Xvnc display
        to match using XRANDR. Treat failure as non-fatal since an
        incorrectly sized console is arguably better than none.
    """
    class UninitializedWindowError(Exception):
        pass

    class UnmappedWindowError(Exception):
        pass

    def _get_window_geometry(display, windowname):
        """ Find the xterm xwindow by name/title and extract its geometry
            Returns: tuple of window [width, height]
            Raises:
                UninitializedWindowError if window not yet initialized
                UnmappedWindowError if window is not viewable/mapped
        """
        cmd = [XWININFO, '-d', display, '-name', windowname]
        xwininfo = subprocess.Popen(cmd, stdout=subprocess.PIPE,
                                    stderr=subprocess.PIPE)
        out, err = xwininfo.communicate()
        retcode = xwininfo.wait()
        if retcode != 0:
            print "Error finding console xwindow info: " + err
            raise UninitializedWindowError

        width = None
        height = None
        mapped = False
        for line in out.splitlines():
            line = line.strip()
            if line.startswith("Map State:"):
                if line.split()[-1] != "IsViewable":
                    # Window is not mapped yet.
                    raise UnmappedWindowError
                else:
                    mapped = True
            if line.startswith("Width:"):
                width = int(line.split()[1])
            elif line.startswith("Height:"):
                height = int(line.split()[1])
            if width and height and mapped:
                return [width, height]
        else:
            # What, no width and height???
            print "No window geometry info returned by " + XWINFINFO
            raise UnmappedWindowError

    retries = 10
    sleep = 1
    uninit_count = 0
    unmap_count = 0
    width = 0
    height = 0
    while uninit_count < retries and unmap_count < retries:
        try:
            width, height = _get_window_geometry(display,
                                                 'Zone Console: ' + zonename)
            print "Discovered xterm geometry: %d x %d" % (width, height)
            break
        except UninitializedWindowError:
            if uninit_count < retries:
                print "xterm window not initialized yet. Retrying in %ds" \
                    % sleep
                uninit_count += 1
                time.sleep(sleep)
                continue
            else:
                print "xterm window is taking too long to initialize"
                break
        except UnmappedWindowError:
            if unmap_count < retries:
                print "Discovered xterm not mapped yet. Retrying in %ds" \
                    % sleep
                unmap_count += 1
                time.sleep(sleep)
                continue
            else:
                print "Discovered xterm window is taking too long to map"
                break
    else:
        print "Too many failed attempts to discover xterm window geometry"
        return

    # Generate a mode line for width and height, with a refresh of 60.0Hz
    cmd = [GTF, str(width), str(height), '60.0', '-x']
    gtf = subprocess.Popen(cmd, stdout=subprocess.PIPE,
                           stderr=subprocess.PIPE)
    out, err = gtf.communicate()
    retcode = gtf.wait()
    if retcode != 0:
        print "Error creating new modeline for VNC display: " + err
        return

    for line in out.splitlines():
        line = line.strip()
        if line.startswith('Modeline'):
            modeline = line.split('Modeline')[1]
            print "New optimal modeline for Xvnc server: " + modeline
            mode = modeline.split()
            break

    # Create a new mode for the Xvnc server using the modeline generated by gtf
    cmd = [XRANDR, '-d', display, '--newmode']
    cmd.extend(mode)
    newmode = subprocess.Popen(cmd, stdout=subprocess.PIPE,
                               stderr=subprocess.PIPE)
    out, err = newmode.communicate()
    retcode = newmode.wait()
    if retcode != 0:
        print "Error creating new xrandr modeline for VNC display: " + err
        return

    # Add the new mode to the default display output
    modename = mode[0]
    cmd = [XRANDR, '-d', display, '--addmode', 'default', modename]
    addmode = subprocess.Popen(cmd, stdout=subprocess.PIPE,
                               stderr=subprocess.PIPE)
    out, err = addmode.communicate()
    retcode = addmode.wait()
    if retcode != 0:
        print "Error adding new xrandr modeline for VNC display: " + err
        return

    # Activate the new mode on the default display output
    cmd = [XRANDR, '-d', display, '--output', 'default', '--mode', modename]
    addmode = subprocess.Popen(cmd, stdout=subprocess.PIPE,
                               stderr=subprocess.PIPE)
    out, err = addmode.communicate()
    retcode = addmode.wait()
    if retcode != 0:
        print "Error setting new xrandr modeline for VNC display: " + err
        return


@contextlib.contextmanager
def lock_available_port(address, port_start, port_end, lockdir):
    """Ensures instance exclusive use of VNC, X11 service ports
       and related resources.
       Generator yields an integer of the port relative to port_start to use.
       eg. 32: VNC port 5932, X11 port 6032, X11 display :32
       lockfile is port specific and prevents multiple instances from
       attempting to use the same port number during SMF start method
       execution.
       Socket binding on address:port establishes that the port is not
       already in use by another Xvnc process
    """
    for n in range(port_end - port_start):
        vncport = port_start + n
        x11port = X11PORT_START + n
        lockfile = os.path.join(lockdir, '.port-%d.lock' % vncport)
        try:
            # Acquire port file lock first to lock out other instances trying
            # to come online in parallel. They will grab the next available
            # port lock.
            lock = open(lockfile, 'w')
            fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)

            try:
                # Check the VNC/RFB and X11 ports.
                for testport in [vncport, x11port]:
                    sock = socket.socket(socket.AF_INET)
                    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
                    try:
                        sock.bind((address, testport))
                    finally:
                        sock.close()

                # Ensure the standard X11 locking files are not present
                # /tmp/.X<n>-lock
                # /tmp/X11-unix/X<n>
                xfiles = ['/tmp/.X%d-lock' % n,
                          '/tmp/X11-unix/X%d' % n]
                for xfile in xfiles:
                    if os.path.exists(xfile):
                        print ("Warning: X11 display :{0} is taken because of "
                               "{1}\nRemove this file if there is no X "
                               "server on display :{0}".format(str(n), xfile))
                        raise Exception

            except (socket.error, Exception):
                lock.close()
                os.remove(lockfile)
                continue
            # Yay, we found a free VNC/X11 port pair.
            yield n
            lock.close()
            os.remove(lockfile)
            break
        except IOError:
            print "Port %d already reserved, skipping" % vncport

if __name__ == "__main__":
    os.putenv("LC_ALL", "C")
    smf_include.smf_main()
