#!/usr/bin/python
# -*- encoding: utf-8; py-indent-offset: 4 -*-
# +------------------------------------------------------------------+
# |             ____ _               _        __  __ _  __           |
# |            / ___| |__   ___  ___| | __   |  \/  | |/ /           |
# |           | |   | '_ \ / _ \/ __| |/ /   | |\/| | ' /            |
# |           | |___| | | |  __/ (__|   <    | |  | | . \            |
# |            \____|_| |_|\___|\___|_|\_\___|_|  |_|_|\_\           |
# |                                                                  |
# | Copyright Mathias Kettner 2016             mk@mathias-kettner.de |
# +------------------------------------------------------------------+
#
# This file is part of Check_MK.
# The official homepage is at http://mathias-kettner.de/check_mk.
#
# check_mk 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 in version 2.  check_mk is  distributed
# in the hope that it will be useful, but WITHOUT ANY WARRANTY;  with-
# out even the implied warranty of  MERCHANTABILITY  or  FITNESS FOR A
# PARTICULAR PURPOSE. See the  GNU General Public License for more de-
# tails. You should have  received  a copy of the  GNU  General Public
# License along with GNU Make; see the file  COPYING.  If  not,  write
# to the Free Software Foundation, Inc., 51 Franklin St,  Fifth Floor,
# Boston, MA 02110-1301 USA.

import os
import sys
import time
import signal
import ConfigParser

try:
    # TODO: We should probably ship this package.
    import pyinotify  # pylint: disable=import-error
except:
    sys.stderr.write("Error: Python plugin pyinotify is not installed")
    sys.exit(1)

def usage():
    sys.stdout.write("Usage: mk_inotify [-g]\n")
    sys.stdout.write("         -g: run in foreground\n\n")

# Available options:
# -g: run in foreground
opt_foreground = False
if len(sys.argv) == 2 and sys.argv[1] == "-g":
    opt_foreground = True

mk_confdir = os.getenv("MK_CONFDIR") or "/etc/check_mk"
mk_vardir = os.getenv("MK_VARDIR")   or "/var/lib/check_mk_agent"

config_filename  = mk_confdir + "/mk_inotify.cfg"
configured_paths = mk_vardir  + "/mk_inotify.configured"
pid_filename     = mk_vardir  + "/mk_inotify.pid"


config = ConfigParser.SafeConfigParser({})
if not os.path.exists(config_filename):
    sys.exit(0)
config_mtime = os.stat(config_filename).st_mtime
config.read(config_filename)

# Configurable in Agent Bakery
heartbeat_timeout         = config.getint("global", "heartbeat_timeout")
write_interval            = config.getint("global", "write_interval")
max_messages_per_interval = config.getint("global", "max_messages_per_interval")
stats_retention           = config.getint("global", "stats_retention")
config.remove_section("global")

def output_data():
    sys.stdout.write("<<<inotify:sep(9)>>>\n")
    if os.path.exists(configured_paths):
        sys.stdout.write(file(configured_paths).read())

    now = time.time()
    for dirpath, _unused_dirnames, filenames in os.walk(mk_vardir):
        for filename in filenames:
            if filename.startswith("mk_inotify.stats"):
                try:
                    the_file = "%s/%s" % (dirpath, filename)
                    filetime = os.stat(the_file).st_mtime
                    file_age = now - filetime
                    if file_age > 5:
                        sys.stdout.write(file(the_file).read())
                    if file_age > stats_retention:
                        os.unlink(the_file)
                except:
                    pass
        break

# Check if another mk_inotify process is already running
if os.path.exists(pid_filename):
    pid = file(pid_filename).read()
    proc_cmdline = "/proc/%s/cmdline" % pid
    if os.path.exists(proc_cmdline):
        cmdline = file(proc_cmdline).read()
        cmdline_tokens = cmdline.split("\0")
        if "mk_inotify" in cmdline_tokens[1]:
            # Another mk_notify process is already running..
            # Simply output the current statistics and exit
            output_data()

            # The pidfile is also the heartbeat file for the running process
            os.utime(pid_filename, None)
            sys.exit(0)


#   .--Fork----------------------------------------------------------------.
#   |                         _____          _                             |
#   |                        |  ___|__  _ __| | __                         |
#   |                        | |_ / _ \| '__| |/ /                         |
#   |                        |  _| (_) | |  |   <                          |
#   |                        |_|  \___/|_|  |_|\_\                         |
#   |                                                                      |
#   +----------------------------------------------------------------------+
#   Reaching this point means that no mk_inotify is currently running

if not opt_foreground:
    try:
        pid = os.fork()
        if pid > 0:
            sys.exit(0)
        # Decouple from parent environment
        os.chdir("/")
        os.umask(0)
        os.setsid()

        # Close all fd
        for fd in range(0, 256):
            try:
                os.close(fd)
            except OSError:
                pass
    except Exception, e:
        sys.stderr.write("Error forking mk_inotify: %s" % e)

    # Save pid of working process.
    file(pid_filename, "w").write("%d" % os.getpid())
#.
#   .--Main----------------------------------------------------------------.
#   |                        __  __       _                                |
#   |                       |  \/  | __ _(_)_ __                           |
#   |                       | |\/| |/ _` | | '_ \                          |
#   |                       | |  | | (_| | | | | |                         |
#   |                       |_|  |_|\__,_|_|_| |_|                         |
#   |                                                                      |
#   +----------------------------------------------------------------------+

folder_configs = {} # Computed configuration
output = []         # Data to be written to disk
def get_watched_files():
    files = set([])
    for folder, attributes in folder_configs.items():
        for filenames in attributes["monitor_files"].values():
            for filename in filenames:
                files.add("configured\tfile\t%s/%s" % (folder, filename))
        if attributes.get("monitor_all"):
            files.add("configured\tfolder\t%s" % (folder))
    return files

def wakeup_handler(signum, frame):
    global output
    if output:
        if opt_foreground:
            sys.stdout.write("%s\n" % "\n".join(output))
            sys.stdout.write("%s\n" % "\n".join(get_watched_files()))
        else:
            filename = "mk_inotify.stats.%d" % time.time()
            file("%s/%s" % (mk_vardir, filename), "w").write("\n".join(output)+"\n")
        output = []

    # Check if configuration has changed -> restart
    if (config_mtime != os.stat(config_filename).st_mtime):
        os.execv(__file__, sys.argv)

    # Exit on various instances
    if not opt_foreground:
        if not os.path.exists(pid_filename): # pidfile is missing
            sys.exit(0)
        if time.time() - os.stat(pid_filename).st_mtime > heartbeat_timeout: # heartbeat timeout
            sys.exit(0)
        if os.getpid() != int(file(pid_filename).read()): # pidfile differs
            sys.exit(0)

    update_watched_folders()
    signal.alarm(write_interval)

def do_output(what, event):
    if event.dir:
        return # Only monitor files

    if len(output) > max_messages_per_interval:
        last_message = "warning\tMaximum messages reached: %d per %d seconds" % \
                    (max_messages_per_interval, write_interval)
        if output[-1] != last_message:
            output.append(last_message)
        return

    path        = event.path
    path_config = folder_configs.get(path)
    if not path_config:
        return # shouldn't happen, maybe on subfolders (not supported)

    filename = os.path.basename(event.pathname)
    if what in path_config["monitor_all"] or\
       filename in path_config["monitor_files"].get(what, []):
        line = "%d\t%s\t%s" % (time.time(), what, event.pathname)
        if map_events[what][1]: # Check if filestats are enabled
            try:
                stats = os.stat(event.pathname)
                line += "\t%d\t%d" % (stats.st_size, stats.st_mtime)
            except Exception:
                pass
        output.append(line)
        if opt_foreground:
            sys.stdout.write("%s\n" % line)


map_events = {
    # Mode     Mask                        Report_filestats (currently unused)
    "access"   : (pyinotify.IN_ACCESS,     False), # pylint: disable=no-member
    "open"     : (pyinotify.IN_OPEN,       False), # pylint: disable=no-member
    "create"   : (pyinotify.IN_CREATE,     False), # pylint: disable=no-member
    "delete"   : (pyinotify.IN_DELETE,     False), # pylint: disable=no-member
    "modify"   : (pyinotify.IN_MODIFY,     False), # pylint: disable=no-member
    "movedto"  : (pyinotify.IN_MOVED_TO,   False), # pylint: disable=no-member
    "movedfrom": (pyinotify.IN_MOVED_FROM, False), # pylint: disable=no-member
    "moveself" : (pyinotify.IN_MOVE_SELF,  False), # pylint: disable=no-member
}

class NotifyEventHandler(pyinotify.ProcessEvent):
    def process_IN_MOVED_TO(self, event):
        do_output("movedto", event)

    def process_IN_MOVED_FROM(self, event):
        do_output("movedfrom", event)

    def process_IN_MOVE_SELF(self, event):
        do_output("moveself", event)
#    def process_IN_CLOSE_NOWRITE(self, event):
#        print "CLOSE_NOWRITE event:", event.pathname
#
#    def process_IN_CLOSE_WRITE(self, event):
#        print "CLOSE_WRITE event:", event.pathname

    def process_IN_CREATE(self, event):
        do_output("create", event)

    def process_IN_DELETE(self, event):
        do_output("delete", event)

    def process_IN_MODIFY(self, event):
        do_output("modify", event)

    def process_IN_OPEN(self, event):
        do_output("open", event)


# Watch manager
wm = pyinotify.WatchManager()
def update_watched_folders():
    for folder, attributes in folder_configs.items():
        if attributes.get("watch_descriptor"):
            if not wm.get_path(attributes["watch_descriptor"].get(folder)):
                del attributes["watch_descriptor"]
        else:
            if os.path.exists(folder):
                new_wd = wm.add_watch(folder, attributes["mask"], rec=True)
                if new_wd.get(folder) > 0:
                    attributes["watch_descriptor"] = new_wd


def main():
    # Read config

    for section in config.sections():
        section_tokens = section.split("|")

        folder = section_tokens[0]
        folder_configs.setdefault(folder, {"add_modes": {},
                                           "del_modes": {},
                                           "all_add_modes": set([]),
                                           "all_del_modes": set([])})

        files = None
        if len(section_tokens) > 1:
            files = set(section_tokens[1:])

        add_modes = set([])
        del_modes = set([])
        for key, value in config.items(section):
            if key in map_events:
                if value == "1":
                    add_modes.add(key)
                else:
                    del_modes.add(key)

        if files:
            for mode in add_modes:
                folder_configs[folder]["add_modes"].setdefault(mode, set([]))
                folder_configs[folder]["add_modes"][mode].update(files)
            for mode in del_modes:
                folder_configs[folder]["del_modes"].setdefault(mode, set([]))
                folder_configs[folder]["del_modes"][mode].update(files)
        else:
            folder_configs[folder]["all_add_modes"].update(add_modes)
            folder_configs[folder]["all_del_modes"].update(del_modes)


    # Evaluate config
    for folder, attributes in folder_configs.items():
        required_modes = set([])
        for mode in attributes["add_modes"].keys():
            if mode not in attributes["all_del_modes"]:
                required_modes.add(mode)

        files_to_monitor = {}
        skip_modes = set([])
        for mode in required_modes:
            files_to_monitor.setdefault(mode, set([]))
            files_to_monitor[mode].update(attributes["add_modes"][mode])
            files_to_monitor[mode] -= attributes["del_modes"].get(mode, set([]))
            if not files_to_monitor[mode]:
                skip_modes.add(mode)

        attributes["monitor_files"] = files_to_monitor
        attributes["monitor_all"]   = attributes["all_add_modes"] - attributes["all_del_modes"]
        attributes["modes"]         = required_modes - skip_modes

        # Determine mask
        attributes["mask"] = 0
        for mode in attributes["modes"]:
            attributes["mask"] |= map_events[mode][0]
        for mode in attributes["monitor_all"]:
            attributes["mask"] |= map_events[mode][0]

    update_watched_folders()
    if opt_foreground:
        import pprint
        sys.stdout.write(pprint.pformat(folder_configs))

    # Save monitored file/folder information specified in mk_inotify.cfg
    file(configured_paths, "w").write("\n".join(get_watched_files())+"\n")

    # Event handler
    eh = NotifyEventHandler()
    notifier = pyinotify.Notifier(wm, eh)

    # Wake up every few seconds, check heartbeat and write data to disk
    signal.signal(signal.SIGALRM, wakeup_handler)
    signal.alarm(write_interval)

    notifier.loop()

if __name__ == '__main__':
    main()
