#!/usr/bin/python3

"""
Interface Rename
"""

import sys, logging
import io
from optparse import OptionParser, OptionGroup
from os.path import join as joinpath, normpath, exists as pathexists
from subprocess import Popen

import xcp.logger as LOG
from xcp.pci import PCI
from xcp.net.biosdevname import all_devices_all_names
from xcp.net.ip import ip_link_set_name
from xcp.net.ifrename.logic import rename
from xcp.net.ifrename.macpci import MACPCI
from xcp.net.ifrename.static import StaticRules
from xcp.net.ifrename.dynamic import DynamicRules
from xcp.net.ifrename.util import niceformat
from xcp.net.mac import MAC

__version__ = "2.0.0"

DATA_DIR = normpath("/etc/sysconfig/network-scripts/interface-rename-data")
BACKUP_DIR = joinpath(DATA_DIR, ".from_install")

LOG_PATH = normpath("/var/log/interface-rename.log")
SRULE_FILE = "static-rules.conf"
DRULE_FILE = "dynamic-rules.json"

def run(dryrun, update, args):
    """
    Run the main logic
    """

    # Grab the current state from biosdevname
    current_eths = all_devices_all_names()
    current_state = []

    for nic in current_eths.keys():
        eth = current_eths[nic]

        if not ( "BIOS device" in eth and
                 "Kernel name" in eth and
                 "Assigned MAC" in eth and
                 "Bus Info" in eth and
                 "all_ethN" in eth["BIOS device"] and
                 "physical" in eth["BIOS device"]
                  ):
            LOG.error("Interface information for '%s' from biosdevname is "
                      "incomplete; Discarding."
                      % (eth.get("Kernel name", "Unknown"),))

        try:
            current_state.append(
                MACPCI(eth["Assigned MAC"],
                       eth["Bus Info"],
                       kname = eth["Kernel name"],
                       order = int(eth["BIOS device"]["all_ethN"][3:]),
                       ppn = eth["BIOS device"]["physical"],
                       label = eth.get("SMBIOS Label", "")
                       ))
        except Exception as e:
            LOG.error("Can't generate current state for interface '%s' - "
                      "%s" % (eth, e))
    current_state.sort()

    LOG.debug("Current state = %s" % (niceformat(current_state),))

    # Parse the static rules
    sr = StaticRules(joinpath(DATA_DIR, SRULE_FILE))

    if not sr.load_and_parse():
        LOG.warning("Failed to parse the static rules.  Attempting to continue "
                    "without any")

    sr.generate(current_state)

    LOG.debug("StaticRules Formulae = %s" %(niceformat(sr.formulae),))
    LOG.debug("StaticRules Rules = %s" %(niceformat(sr.rules),))

    # Parse the dynamic rules
    dr = DynamicRules(joinpath(DATA_DIR, DRULE_FILE))

    if not dr.load_and_parse():
        LOG.warning("Failed to parse the dynamic rules.  Attempting to continue"
                    " without any")

    LOG.debug("DynamicRules Lastboot = %s" % (niceformat(dr.lastboot),))
    LOG.debug("DynamicRules Old = %s" % (niceformat(dr.old),))

    # If we are attempting a manual update
    if update:
        # Parse the args as static rules
        ur = StaticRules(fd=io.StringIO('\n'.join(args)))

        if not ur.load_and_parse():
            LOG.error("Failed to parse the update rules")
            return

        LOG.debug("UpdateRules Formulae = %s" %(niceformat(ur.formulae),))
        ur.generate(current_state)
        LOG.debug("UpdateRules Rules = %s" %(niceformat(ur.rules),))

        if len(ur.rules) < 1:
            LOG.error("No valid update rules after processing.  Doing nothing")
            return

        all_srule_eths = list(sr.formulae.keys()) + list(ur.formulae.keys())
        all_srules = sr.rules + ur.rules
        if ( len(all_srule_eths) != len(set(all_srule_eths)) or
             len(all_srules) != len(set(all_srules)) ):
            LOG.error("Update rules and static rules overlap.  Doing nothing")
            return
        else:
            sr.rules.extend(ur.rules)


    # Invoke the renaming logic
    try:
        transactions = rename(static_rules = sr.rules,
                              cur_state = current_state,
                              last_state = dr.lastboot,
                              old_state = dr.old)
    except Exception as e:
        LOG.critical("Problem from rename logic: %s.  Giving up" % (e,))
        return

    # If we are performing an manual update and not already logging to stdout,
    # start logging so the user sees messages regarding needing to reboot
    if update and not dryrun:
        LOG.logToStdout()

    # Apply transactions, or explicitly state that there are none
    if len (transactions):
        if update:
            LOG.info("Performing manual update of rules.  Not actually "
                     "renaming interfaces")
        else:
            for src, dst in transactions:
                if dryrun:
                    LOG.info("Would rename '%s' to '%s' if not dry run"
                             % (src, dst))
                else:
                    ip_link_set_name(src, dst)
    else:
        LOG.info("No transactions.  No need to rename any nics")

    # Regenerate dynamic configuration
    def macpci_as_list(x):
        return [str(x.mac), str(x.pci), x.tname]

    new_lastboot = [macpci_as_list(x) for x in current_state]
    new_macs = frozenset( (x.mac for x in current_state) )
    new_old = [macpci_as_list(x) for x in dr.lastboot + dr.old if x.mac not in new_macs]

    LOG.debug("New lastboot data=\n%s" % (niceformat(new_lastboot),))
    LOG.debug("New old data=\n%s" % (niceformat(new_old),))

    if dryrun:
        LOG.info("Would update the dynamic configuration if not dry run")
    else:
        dr.lastboot = new_lastboot
        dr.old = new_old
        dr.save()

    if update and not len(transactions):
        LOG.info("Done - Please reboot to safely rename interfaces")
    else:
        LOG.info("All done")

def listdevs():
    """
    List physical devices and associated information.
    """

    # Grab the current state from biosdevname
    current_eths = all_devices_all_names()

    # Column titles
    eths = [("Name", "MAC", "PCI", "ethN", "Phys", "SMBios", "Driver",
             "Version", "Firmware")]

    # Sort keys, ethN first in ascending N, followed by others
    keys = current_eths.keys()
    order = ( sorted( (k for k in keys if k.startswith('eth')),
                      key=lambda x : int(x[3:])) +
              sorted( (k for k in keys if not k.startswith('eth'))))

    for nic in order:
        eth = current_eths[nic]

        if not ( "BIOS device" in eth and
                 "Kernel name" in eth and
                 "Assigned MAC" in eth and
                 "Bus Info" in eth and
                 "all_ethN" in eth["BIOS device"] and
                 "physical" in eth["BIOS device"] and
                 "Driver" in eth
                 ):
            LOG.error("Interface information for '%s' from biosdevname is "
                      "incomplete; Discarding."
                      % (eth.get("Kernel name", "Unknown"),))

        try:
            eths.append(
                ( eth["Kernel name"], str(MAC(eth["Assigned MAC"])),
                  str(PCI(eth["Bus Info"])), eth["BIOS device"]["all_ethN"],
                  eth["BIOS device"]["physical"],
                  eth.get("SMBIOS Label", ""), eth["Driver"],
                  eth.get("Driver version", ""),
                  eth.get("Firmware version", "")
                  ))
        except Exception as e:
            LOG.error("Can't generate current state for interface '%s' - "
                      "%s" % (eth, e))

    # Calculate maximum widths of each column in the table
    widths = []
    for x in range(len(eths[0])):
        widths.append( max((len(row[x]) for row in eths)) )

    # Create a format string based on calculated widths
    fmt_str = ('  '.join(["%%-%ds"]*len(widths)) % tuple(widths))

    # Print table
    for eth in eths:
        print(fmt_str % eth)


def reset(dryrun):
    """
    Reset interface configuration to how it was on boot.  This is required for
    xe pool-eject
    """
    import shutil

    srule_src = joinpath(BACKUP_DIR, SRULE_FILE)
    srule_dst = joinpath(DATA_DIR, SRULE_FILE)
    drule_src = joinpath(BACKUP_DIR, DRULE_FILE)
    drule_dst = joinpath(DATA_DIR, DRULE_FILE)

    # Revert static rules file if possible
    if pathexists(srule_src):
        if dryrun:
            LOG.info("Would copy '%s' to '%s' if not dry run"
                     % (srule_src, srule_dst))
        else:
            shutil.copy2(srule_src, srule_dst)
            Popen(['sed', r's/pci\([0-9]\+p[0-9]\+\)/p\1/g', '-i',
                   srule_dst]).communicate()
            LOG.debug("Copied '%s' to '%s'" % (srule_src, srule_dst))
    else:
        LOG.warning("Installer file '%s' not found.  Ignoring reset"
                    % (srule_src,))

    # Revert dynamic rules file if possible
    if pathexists(drule_src):
        if dryrun:
            LOG.info("Would copy '%s' to '%s' if not dry run"
                     % (drule_src, drule_dst))
        else:
            shutil.copy2(drule_src, drule_dst)
            LOG.debug("Copied '%s' to '%s'" % (drule_src, drule_dst))
    else:
        LOG.warning("Installer file '%s' not found.  Ignoring reset"
                    % (drule_src,))

    LOG.info("All Done")


def main(argv = sys.argv):
    """
    Parse command line arguments and set up basic logging
    """

    parser = OptionParser(
        usage =
        ("usage: %prog --rename|--list|--update <args>|"
         "--reset-to-install [-v] [-d]"),
        description =
        ("Utility for managing the naming of physical network interfaces.  It "
         "is used "
         "to undo the damage of race condition for device drivers grabbing "
         "eth names on boot, taking into account naming policies provided at "
         "install time.  In addition, it implements sensible policies when "
         "network hardware changes, and the ability for manual alteration of "
         "the policies after install."),
        version = "%%prog %s" % (__version__, )
        )

    # Misc options
    parser.add_option("-v", "--verbose", action = "store_true",
                      dest = "verbose", default = False,
                      help = "increase logging")
    parser.add_option("-d", "--dry-run", action = "store_true",
                      dest = "dryrun", default = False,
                      help = "dry run - don't write any state back to disk")

    # Actions
    actions = OptionGroup(parser, "Actions",
                          "Exactly one action is expected")
    actions.add_option("-r", "--rename", action = "store_true",
                       dest = "rename", default = False,
                       help = "rename physical interfaces.  It is not safe to "
                       "rename interfaces which have traffic passing, or "
                       "higher level networking constructs on them (bonds/"
                       "bridges/etc).  Use at your own risk after boot"
                       )
    actions.add_option("-l", "--list", action = "store_true",
                       dest = "listdevs", default = False,
                       help = "list current physical device information in a "
                       "concise manner as a reference for --update"
                       )
    actions.add_option("-u", "--update", action = "store_true",
                       dest = "update", default = False,
                       help = "manually update the order of devices.  <args> "
                       "should be one or more <target eth name>=MAC|PCI|Phys|"
                       "\"SMBios\""
                       )
    actions.add_option("--reset-to-install", action = "store_true",
                       dest = "reset", default = False,
                       help = "reset configuration to install state")
    parser.add_option_group(actions)

    if len(argv) == 1:
        parser.print_help()
        return

    options, args = parser.parse_args()

    all_actions = [options.rename, options.listdevs, options.update,
                   options.reset ]
    selected_actions = [ x for x in all_actions if x ]

    if len(selected_actions) == 0:
        parser.error("Action expected")

    if len(selected_actions) > 1:
        parser.error("Expected only 1 action")


    # Choose logging level based on verboseness
    if options.verbose:
        loglvl = logging.DEBUG
    else:
        loglvl = logging.INFO

    # Choose logging destination based on dryrun or not
    if options.dryrun:
        LOG.closeLogs()
        LOG.logToStdout(loglvl)
        LOG.info("Dry Run - logging to stdout instead of '%s'"
                 % ( LOG_PATH, ))
    else:
        LOG.openLog(LOG_PATH, loglvl)

    LOG.debug("Started script with command line '%s'" % ' '.join(argv))

    # Conditionally log verbosity
    if options.verbose:
        LOG.debug("Verbose logging enabled")

    # Actually do some work
    if options.reset:
        reset(options.dryrun)
    elif options.listdevs:
        listdevs()
    else:
        run(options.dryrun, options.update, args)

    LOG.closeLogs()

    return 0

if __name__ == "__main__":
    import traceback
    ret = 255

    LOG.logToStderr(logging.ERROR)

    try:
        ret = main()

    except SystemExit as e:
        # parser --help raises SystemExit - let it pass
        ret = e.code

    except:
        LOG.critical(traceback.format_exc())
    sys.exit(ret)
