#!/usr/bin/python2

# Copyright 2016 Rackspace, Inc.
#
# 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 os
import os.path
import optparse
import pwd
import grp
import sys
import subprocess
import datetime
import difflib
import glob
import re

version="0.12"

def report_verbose(message):
    if options.verbose_enabled:
        sys.stdout.write("\033[92m%s\n\033[0m" % message)
def report_info(message):
    if not options.silent_enabled:
        sys.stdout.write("\033[92m%s\n\033[0m" % message)
def report_info_blue(message):
    if not options.silent_enabled:
        sys.stdout.write("\033[1;34m%s\n\033[0m" % message)
def report_error(message):
    if not options.silent_enabled:
        sys.stderr.write("\033[91m%s\n\033[0m" % message)

diffs_found_msg = False
is_php_detected = False

class dataGather:

    def copy_file(self, r_filename, destdir=None, fail_ok=False, sort=False):
        # Did we override the destination
        if destdir is None:
            destdir = self.workdir

        if not os.path.exists(r_filename):
            if not fail_ok:
                report_error(" %s does not exist" % r_filename)
            return
        filename = os.path.basename(r_filename)
        w_filename = os.path.join(destdir, "%s.%s" %
                                  (filename, self.phase))

        try:
            r_fd = open(r_filename, 'r')
            w_fd = open(w_filename, 'w')

            if sort is False:
                w_fd.write(r_fd.read())
            else:
                data = r_fd.readlines()
                data.sort()
                w_fd.writelines(data)

            r_fd.close()
            w_fd.close()
            if not options.silent_enabled:
                sys.stdout.write(" %s \n" % r_filename)

        except IOError:
            report_error("Error copying %s" % w_filename)

    def run_command(self, command, filename, fail_ok=False,
                    sort=False, stdout=False, shell=False):
        try:
            command_proc = subprocess.Popen(
                command, stdout=subprocess.PIPE,
                stderr=subprocess.PIPE, shell=shell)
        except OSError, e:
            if not fail_ok:
                report_error("Error running %s: %s" %
                             (command[0], e.strerror))
                return None
            else:
                return None

        output = command_proc.stdout.readlines()
        returncode = command_proc.wait()

        if not fail_ok and returncode != 0:
            report_error("%s failed\nPlease troubleshoot manually" % (' '.join(command), ))
            return None

        if len(output) == 0:
            return True

        if sort:
            output.sort()

        filename = os.path.join(self.workdir, "%s.%s" % (filename, self.phase))

        if os.path.exists(filename):
            report_error("%s already exists" % filename)

        try:
            output_fd = open(filename, 'w')
        except IOError, e:
            report_error("Unable to open %s: %s" % (filename, e.strerror))
            return False

        output_fd.writelines(output)
        output_fd.close()
        report_verbose("Recording %s to %s" % (command, filename))
        if stdout:
            sys.stdout.writelines(output)

    def get_diff(self, filename):
        global diffs_found_msg
        filename = os.path.join(self.workdir, filename)
        pre_filename = "%s.pre" % filename
        post_filename = "%s.%s" % (filename, self.phase)
        try:
            pre_fd = open(pre_filename)
            post_fd = open(post_filename)
        except IOError, e:
            report_error("Unable to open file %s: %s" %
                         (e.filename, e.strerror))
            return False

        pre = pre_fd.readlines()
        post = post_fd.readlines()
        pre_fd.close()
        post_fd.close()
        found_diff = False
        for line in difflib.ndiff(pre, post):
            if line[0] in ('+', '-', '?'):
                if not found_diff:
                    found_diff = True
                    diffs_found_msg = True
                    report_error("Differences found against %s.pre:\n" % filename)
                sys.stdout.write(line)

        if not found_diff:
           report_info("No differences against %s.pre" % filename)

    def __init__(self, workdir, phase):
        self.workdir = workdir
        self.phase = phase

desc='Record useful system state information, and compare to previous state if run with PHASE containing "post" or "rollback".'

parser = optparse.OptionParser(description=desc)
parser.add_option("-v", "--verbose",
                  action="store_true", dest="verbose_enabled", default=False,
                  help="print debug info")
parser.add_option("-V", "--version",
                  action="store_true", dest="print_version", default=False,
                  help="print version")
parser.add_option("-s", "--silent",
                  action="store_true", dest="silent_enabled", default=False,
                  help="no output to stdout")
parser.add_option('-t', '--tag', dest='tag',
                  help='tag identifer (e.g. a ticket number)')
parser.add_option('-d', '--basedir', dest='basedir', default='/root',
                  help='base directory to store output')
parser.add_option('-p', '--phase', dest='phase',
                  help='phase this is being used for. '
                       'Can be any string. Phases containing  post  or  rollback  will perform diffs')
(options, args) = parser.parse_args()

if options.print_version:
    print "configsnap %s" % version
    sys.exit(0)

if os.geteuid() != 0:
    report_error("Not running as root, exiting")
    sys.exit(1)

if not options.tag:
    report_error("No tag given, exiting")
    sys.exit(1)

tagdir = os.path.join(options.basedir, options.tag)
workdir = os.path.join(tagdir, "configsnap")

if not os.path.isdir(workdir):
    try:
        os.makedirs(workdir)
    except OSError:
        report_error("Unable to create %s" % workdir)

os.chdir(workdir)

if not "PATH" in os.environ:
  os.environ['PATH'] = '/usr/bin:/bin'
os.environ['PATH'] += ':/usr/sbin:/sbin'

if options.phase:
    phase = options.phase
    for filename in os.listdir('.'):
        if filename.endswith(".%s" % phase):
            report_error("Files for %s already exist in %s" % (phase,
	    	os.getcwd()))
            sys.exit(1)
else:
    now = datetime.datetime.now()
    phase = "%d%02d%02d%02d%02d" % (now.year, now.month, now.day,
                                    now.hour, now.minute)

# Load custom collection list
customcollectionfile = '/etc/configsnap/additional.conf'
Config = ConfigParser.ConfigParser()

if os.path.exists(customcollectionfile):
    # Check file is owned by root and not read/writable by anyone else
    st = os.stat(customcollectionfile)
    if st.st_uid != 0:
        report_error("Custom collection file %s not owned by root, ignoring" % customcollectionfile)
    elif oct(st.st_mode)[-2:] != '00':
        report_error("Custom collection file %s is read or writable by non-root, ignoring" % customcollectionfile)
    else:
        # Only read in the config file if the above checks have passed
        Config.read(customcollectionfile)

# Ensure each section has a valid Type
for section in Config.sections():
    if Config.has_option(section, 'type'):
        if Config.get(section, 'type').lower() != "file" and Config.get(section, 'type').lower() != "command":
            report_error("Wrong \"Type\" defined for custom collection entry \"%s\"; should be \"file\" or \"command\"" % section)

# Array to hold custom collection files and commands to compare
additional_to_compare = []

# Get a list of processes running on the server
procs = subprocess.Popen(
    ['ps', '-A', '--noheaders', '-o', 'args'], stdout=subprocess.PIPE,
    stderr=subprocess.PIPE)

procs_output = procs.communicate()[0]
if procs.returncode != 0:
    report_error('Failed to get process list')

# Just keep the process name, drop the arguments
procs_output = procs_output.splitlines()
procs_output = [x.split()[0] for x in procs_output]

run = dataGather(workdir, phase)
report_info("Getting storage details (LVM, partitions, multipathing)...")
if os.path.exists('/sbin/lvs'):
    run.run_command(['lvs', '--noheadings'], 'lvs', fail_ok=True, sort=True)
    run.run_command(['vgs', '--noheadings'], 'vgs', fail_ok=True, sort=True)
    run.run_command(['pvs', '--noheadings'], 'pvs', fail_ok=True, sort=True)

    try:
        vgcfg = subprocess.Popen(['vgcfgbackup', '-f',
                                  os.path.join(workdir,
                                  "vgcfgbackup-%%s.%s" % phase)],
                                  stdout=subprocess.PIPE,
                                  stderr=subprocess.PIPE)
        vgcfg.wait()
    except:
        report_error("vgcfgbackup failed")
    else:
        if vgcfg.returncode != 0:
            report_error("vgcfg exited with return code %d" % vgcfg.returncode)

run.run_command(['parted', '-l', '-s'], 'partitions', fail_ok=False, sort=False)
if os.path.exists('/etc/multipath.conf'):
    run.run_command(['multipath', '-l'], 'multipath', fail_ok=True, sort=False)
if os.path.exists('/sbin/powermt'):
    run.run_command(['powermt', 'display', 'dev=all'],
                    'powermt', fail_ok=True, sort=False, stdout=False)

report_info("Getting process list...")
run.run_command(['ps', 'a', 'u', 'f', 'x'], 'ps', fail_ok=False, sort=False)

report_info("Getting package list and enabled services...")
run.run_command(['uname', '-r'], 'uname', fail_ok=False, sort=False)
if os.path.exists('/bin/rpm'):
    run.run_command(['rpm', '-qa'], 'packages', fail_ok=False, sort=True)
if os.path.exists('/usr/bin/dpkg'):
    run.run_command(['dpkg', '-l'], 'packages', fail_ok=False, sort=False)
if os.path.exists('/sbin/chkconfig'):
    run.run_command(['chkconfig', '--list'], 'sysvinit', fail_ok=False, sort=True)
if os.path.exists('/usr/sbin/sysv-rc-conf'):
    run.run_command(['sysv-rc-conf', '--list'], 'sysvinit', fail_ok=True, sort=True)
if os.path.exists('/sbin/initctl'):
    run.run_command(['initctl', 'list'], 'upstartinit', fail_ok=True, sort=True)
if os.path.exists('/usr/bin/systemctl'):
    run.run_command(['systemctl', 'list-unit-files'], 'systemdinit', fail_ok=True, sort=False)

report_info("Getting network details, firewall rules and listening services...")
run.run_command(['ip', 'address', 'show'],
                'ip_addresses', fail_ok=False, sort=False)
run.run_command(['ip', 'route', 'show'],
                'ip_routes', fail_ok=False, sort=False)
run.run_command(['iptables', '--list-rules'], 'iptables', fail_ok=True, sort=False)
run.run_command(
    "netstat -nutlp|awk -F'[ /]+' '/tcp/ {print $8,$1,$4}'|column -t",
    'netstat', fail_ok=False, sort=True, shell=True)

report_info("Getting cluster status...")
if os.path.exists('/usr/sbin/clustat'):
    run.run_command(['clustat', '-l'], 'clustat',
                    fail_ok=False, sort=False, stdout=False)
if os.path.exists('/usr/sbin/pcs'):
    run.run_command(['pcs', 'status'], 'pcs_status',
                    fail_ok=False, sort=False, stdout=False)
    run.run_command(['pcs', 'constraint', 'show', '--full'], 'pcs_constraints',
                    fail_ok=False, sort=False, stdout=False)

report_info("Getting misc (dmesg, lspci, sysctl)...")
run.run_command(['dmesg'], 'dmesg', fail_ok=False, sort=False)
if os.path.exists('/sbin/lspci') or os.path.exists('/usr/bin/lspci'):
    run.run_command(['lspci', '-mm'], 'lspci', fail_ok=True, sort=True)

run.run_command(['sysctl', '-a'], 'sysctl', fail_ok=True, sort=True)

omreportpath='/opt/dell/srvadmin/bin/omreport'
if os.path.exists(omreportpath):
    report_info("Getting Dell hardware information...")
    run.run_command([omreportpath, 'chassis'],
                    'omreport_chassis', fail_ok=True,
                    sort=False, stdout=False)
    run.run_command([omreportpath, 'chassis', 'biossetup'],
                    'omreport_biossetup', fail_ok=True,
                    sort=False, stdout=False)
    run.run_command([omreportpath, 'system', 'version'],
                    'omreport_version', fail_ok=True,
                    sort=False, stdout=False)
    run.run_command([omreportpath, 'chassis', 'memory'],
                    'omreport_memory', fail_ok=True,
                    sort=False, stdout=False)
    run.run_command([omreportpath, 'chassis', 'processors'],
                    'omreport_processors', fail_ok=True,
                    sort=False, stdout=False)
    run.run_command([omreportpath, 'chassis', 'nics'],
                    'omreport_nics', fail_ok=True,
                    sort=False, stdout=False)
    run.run_command([omreportpath, 'storage', 'vdisk'],
                    'omreport_vdisk', fail_ok=True,
                    sort=False, stdout=False)
    run.run_command([omreportpath, 'storage', 'pdisk', 'controller=0'],
                    'omreport_pdisk', fail_ok=True, sort=False, stdout=False)
    run.run_command([omreportpath, 'storage', 'battery'],
                    'omreport_raidbattery', fail_ok=True, sort=False, stdout=False)

hpasmpath='/sbin/hpasmcli'
dmipath='/usr/sbin/dmidecode'
hpstorutil=['/usr/sbin/hpssacli','/usr/sbin/hpacucli']
if os.path.exists(hpasmpath):
    report_info("Getting HP hardware information...")
    run.run_command([hpasmpath, '-s', 'show server'],
                    'hpreport_server', fail_ok=True,
                    sort=False, stdout=False)
    run.run_command([hpasmpath, '-s', 'show dimm'],
                    'hpreport_memory', fail_ok=True,
                    sort=False, stdout=False)
    if os.path.exists(dmipath):
        run.run_command([dmipath, '-t', 'bios'],
                        'hpreport_bios', fail_ok=True,
                        sort=False, stdout=False)
    for storutil in hpstorutil:
        if os.path.exists(storutil):
            report_info("Getting HP storage data...")
            run.run_command([storutil, 'controller', 'all', 'show', 'detail'],
                            'hpreport_storage_controller', fail_ok=True,
                            sort=False, stdout=False)
            # Grab the ID of the first controller reported
            # This returns something like:
            # "Smart Array P400 in Slot 1"
            stor_cmd = subprocess.Popen(
                [storutil, 'controller', 'all', 'show'],
                stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            stor_out = stor_cmd.stdout.readlines()
            stor_cmd.wait()
            for o in stor_out:
                m = re.search('lot ([0-9]+)', o)
                if m:
                    run.run_command(
                        [storutil, 'controller', "slot=%s" % m.group(1), 'logicaldrive', 'all', 'show', 'detail'],
                        'hpreport_storage_vdisk', fail_ok=True, sort=False, stdout=False)
                    run.run_command(
                        [storutil, 'controller', "slot=%s" % m.group(1), 'physicaldrive', 'all', 'show', 'detail'],
                        'hpreport_storage_pdisk', fail_ok=True, sort=False, stdout=False)
                    break
            break

if '/usr/sbin/httpd' in procs_output or '/usr/sbin/httpd.worker' in procs_output:
    report_info("Getting Apache vhosts and modules...")
    run.run_command(
        ['httpd', '-S'], 'httpd_vhosts', fail_ok=False, sort=False)
    run.run_command(
        ['httpd', '-M'], 'httpd_modules', fail_ok=False, sort=True)

if '/usr/libexec/mysqld' in procs_output:
    report_info("Getting MySQL databases...")
    run.run_command(['mysql', '-Bse', 'show databases;'],
                    'mysql_databases', fail_ok=True, sort=True)

# Run any custom commands
for section in Config.sections():
    try:
        cfgtype = Config.get(section, 'type').lower()
        if cfgtype == 'command':
            cmd = Config.get(section, 'command')

            # Optional options
            if Config.has_option(section, 'failok') and Config.get(section, 'failok').lower() == "true":
              failokcfg = True
            # If failok is anything except true...
            else:
              failokcfg = False

            if Config.has_option(section, 'sort') and Config.get(section, 'sort').lower() == "true":
              sortcfg = True
            # If sort is anything except true...
            else:
              sortcfg = False

            if Config.has_option(section, 'compare') and Config.get(section, 'compare').lower() == "true":
              additional_to_compare.append(section)

            report_info("Running custom command %s: %s" % (section, cmd))
            run.run_command(cmd.split(), section, fail_ok=failokcfg, sort=sortcfg)
    except Exception, e:
        report_error("Check config file options for section %s: %s" % (section, e))

# copy important files
report_info("Copying files...")
run.copy_file('/boot/grub/grub.conf', fail_ok=True)
run.copy_file('/boot/grub2/grubenv', fail_ok=True)
run.copy_file('/etc/default/grub', fail_ok=True)
run.copy_file('/etc/fstab', fail_ok=False)
run.copy_file('/etc/hosts', fail_ok=False)
run.copy_file('/etc/sysconfig/network', fail_ok=True)
run.copy_file('/etc/network/interfaces', fail_ok=True)
run.copy_file('/etc/cluster/cluster.conf', fail_ok=True)
run.copy_file('/etc/yum.conf', fail_ok=True)
run.copy_file('/etc/dnf/dnf.conf', fail_ok=True)
run.copy_file('/etc/apt/sources.list', fail_ok=True)
run.copy_file('/proc/cmdline', fail_ok=False)
run.copy_file('/proc/meminfo', fail_ok=False)
run.copy_file('/proc/mounts', fail_ok=False, sort=False)
run.copy_file('/proc/scsi/scsi', fail_ok=False, sort=False)
for filename in glob.glob('/etc/sysconfig/network-scripts/ifcfg-*'):
    run.copy_file(filename, fail_ok=False)
for filename in glob.glob('/etc/sysconfig/network-scripts/route-*'):
    run.copy_file(filename, fail_ok=False)

# copy custom files
for section in Config.sections():
    try:
        cfgtype = Config.get(section, 'type').lower()
        if cfgtype == 'file':
            cpfile = Config.get(section, 'file')

            # Optional options
            if Config.has_option(section, 'failok') and Config.get(section, 'failok').lower() == "true":
              failokcfg = True
            # If failok is anything except true...
            else:
              failokcfg = False

            if Config.has_option(section, 'compare') and Config.get(section, 'compare').lower() == "true":
              additional_to_compare.append(section)

            run.copy_file(cpfile, fail_ok=failokcfg)
    except Exception, e:
        report_error("Check config file options for section %s: %s" % (section, e))

report_info("\n")

# php specifics
if os.path.exists('/etc/php.ini'):
    is_php_detected = True
    report_info("Copying PHP related files...")
    run.copy_file('/etc/php.ini', fail_ok=True)
    destdir = os.path.join(workdir, "php.d/")
    if not os.path.exists(destdir):
        os.makedirs(destdir)

    for filename in glob.glob('/etc/php.d/*'):
        run.copy_file(filename, destdir, fail_ok=False)
    run.run_command(
        ['php', '-m'], 'php_modules', fail_ok=False, sort=False)
    run.run_command(
        ['php', '-i'], 'php_info', fail_ok=False, sort=False)
    if os.path.exists('/usr/bin/pecl'):
        run.run_command(['pecl', 'list'], 'pecl-list', fail_ok=True, sort=False)

if ('post' in phase or 'rollback' in phase) and not options.silent_enabled:
    # Check that there are pre files, and exit if there aren't
    found_pre = False
    for filename in os.listdir(workdir):
        if filename.endswith('pre'):
            found_pre = True
    if not found_pre:
        report_error("Unable to diff, as no pre files")
        sys.exit(0)

    report_info("This is  %s  phase so performing diffs..." % phase)

    for filename in ('sysvinit', 'mounts', 'netstat', 'ip_addresses',
                     'ip_routes', 'partitions', 'lspci'):
        run.get_diff(filename)

    if is_php_detected:
        run.get_diff('php.ini')
        if os.path.exists('/usr/bin/pecl'):
            run.get_diff('pecl-list')
        for filename in glob.glob(os.path.join(workdir, "php.d/*pre")):
            filename = str.replace(filename, ".pre", "")
            run.get_diff(filename)

    if 'rollback' in phase:
        run.get_diff('packages')

    compare = [ 'lvs', 'pvs', 'vgs', 'powermt', 'omreport_version',
                     'omreport_memory', 'omreport_processors',
                     'omreport_nics', 'omreport_vdisk', 'omreport_pdisk',
                     'hpreport_server','hpreport_memory','hpreport_bios',
                     'hpreport_storage_controller','hpreport_storage_vdisk',
                     'hpreport_storage_pdisk', 'multipath', 'systemdinit',
                     'upstartinit', 'yum.conf', 'dnf.conf', 'pcs_constraints' ]

    compare.extend(additional_to_compare)

    for filename in compare:
        pre_filename = os.path.join(workdir,
                                    "%s.pre" % filename)
        if os.path.exists(pre_filename):
            run.get_diff(filename)

    uname_pre_fd = open(
        os.path.join(workdir, 'uname.pre'), 'r')
    uname_post_fd = open(
        os.path.join(workdir, "uname.%s" % phase), 'r')
    uname_pre = uname_pre_fd.read().strip()
    uname_post = uname_post_fd.read().strip()
    if uname_pre != uname_post:
        sys.stdout.write("Old uname: %s. New uname: %s\n" %
                         (uname_pre, uname_post))

    if diffs_found_msg:
        report_info_blue("\nINTERPRETING DIFFS:\n")
        report_info_blue("* Lines beginning with a - show an entry from the .pre file which has changed\nor which is not present in the .post or .rollback file.\n")
        report_info_blue("* Lines beginning with a + show an entry from the .post or.rollback file which\nhas changed or which is not present in the .pre file.\n")
        report_info_blue("Please review all diff output above carefully and account for any differences\nfound.\n")

# cleanup
report_info("Finished! Backups were saved to %s/*.%s" % (workdir, phase))
