#!/usr/bin/env python

# Copyright C 2002-2008 by Isilon Systems, Inc. All rights not expressly
# granted herein are reserved.
# Created in the United States of America.
# 
# This digital media is protected by U.S. and international copyright and
# intellectual property laws. Unless otherwise specified, all information
# and screens appearing as part of this digital medium, including
# software, services, documents, text, images, icons, and logos design;
# the selection, assembly, arrangement, and design thereof; and the code
# that enables its presentation, are the sole property of Isilon Systems,
# Inc.
# 
# Except as otherwise required by applicable law, no part of any
# copyrighted material may be reproduced, modified, translated,
# distributed, published, or transmitted by any means, electronic or
# mechanical, for any purpose, without the prior written consent of Isilon
# Systems. 
#  
# Isilon and Isilon Systems are registered trademarks of Isilon Systems,
# Inc.  Legion is a trademark of Isilon Systems, Inc.  All other
# trademarks and registered trademarks are the property of their
# respective holders.

import os
import sys
import syslog
import time
import isi.app.lib.files as files
import isi.app.lib.procs as procs
import isi.fs.journal as jlib
import isi.hw.check.lib.args as argslib
import isi.config.arrcfg as arrcfg
import isi.misc
import isi.error

from isi.ui.shell.readonlycmd import READ_ONLY_BITS

def get_bits(bits):
    result = 0
    for bit in bits:
        result |= READ_ONLY_BITS[bit]
    return result

def read_only_string(read_only):
    return arrcfg.read_only_bool(read_only) and 'read-only' or 'read/write'

def main(args):
    """Main entry point to the script.

       Should always be run indirectly via argslib.script_main() or
       argslib.main(). The args parameter is expected to be an argslib.Args
       object.
       """
    syslog.openlog('isi_read_only', syslog.LOG_PID, syslog.LOG_USER)

    switcher = Switcher(args)
    return switcher.run()

class Switcher:
    def __init__(self, args):
        self.args = args
        # Always print errors to console and syslog at LOG_ERR level, which
        # will send the message to both /var/log/isi_read_only.log and
        # /var/log/messages (where someone is more likely to see it).
        self.ERR_OPTS = {
            'force' : True,
            'level' : syslog.LOG_ERR,
            'error' : True,
            }
        # Always print extra error message to console, but only syslog
        # to /var/log/isi_read_only.log so we don't spam /var/log/messages
        # with extra info
        self.EXT_ERR_OPTS = {
            'force' : True,
            'error' : True,
            }

    def __out(self, line, force=False, level=syslog.LOG_INFO, error=False):
        # Always syslog at the given level
        syslog.syslog(level, line)
        # Print to console when applicable
        if self.args['verbose'] or force:
            if error:
                print >> sys.stderr, line
                sys.stderr.flush()
            else:
                print >> sys.stdout, line
                sys.stdout.flush()

    def run(self):
        error = 0

        nonopts = self.args.nonopts()
        if len(nonopts) > 0:
            raise argslib.Error('too many arguments: %s' % ' '.join(nonopts))

        ops = (self.args.get_tally_bool('get') +
               self.args.get_tally_bool('set') +
               self.args.get_tally_bool('unset'))

        # If no actions have been specified, default to 'get'
        if ops == 0:
            self.args['get'] = True
            ops = 1

        # Make sure get and set/unset are mutually exclusive
        if ops > 1:
            raise argslib.Error('Only one of get/set/unset actions may be ' +
                                'specified')

        # Do something useful!
        if self.args['apply'] is not None:
            error = self.__set_unset_apply()
        elif self.args.get_tally_count('set/unset'):
            error = self.__set_unset()
        elif self.args['get']:
            error = self.__get()

        return error

    def __save_journal(self):
        # XXX MAX_SLEEP_SECS to be set back to 2 by modifying gmp after
        # bug 85401 is fixed
        MAX_SLEEP_SECS = 30
        SLEEP_INTERVAL_SECS = 0.1
        total_slept_secs = 0

        # Check if /ifs is currently mounted on this node
        ifs_mounted = isi.misc.ifs_mounted()
        if ifs_mounted:
            self.__out("Waiting for journal to become read-only")
        else:
            self.__out("OneFS is not mounted")

        # Wait for journal to say it's locked
        serial = 0
        while ifs_mounted:
            try:
                serial = jlib.read_only_serial()
            except jlib.Error, e:
                self.__out(e.msg(), **self.ERR_OPTS)
                for line in e.output():
                    self.__out('  %s' % line, **self.EXT_ERR_OPTS)
                return e.error()

            # If the journal is locked, move on to saving, otherwise
            # sleep for a bit and then try again
            if serial:
                self.__out("read-only at serial %s" % serial)
                break
            else:
                time.sleep(SLEEP_INTERVAL_SECS)
                total_slept_secs += SLEEP_INTERVAL_SECS
                if total_slept_secs >= MAX_SLEEP_SECS:
                    self.__out("Giving up on waiting for journal")
                    return 1 # It's just never gonna happen

        if self.args['verbose']:
            func = lambda line: self.__out('  %s' % line.rstrip())
        else:
            func = None

        # This command is expected to fail... we just want to verify that.
        # So redirect to /dev/null.
        cmd = "/usr/sbin/isi_save_journal --validate >> /dev/null"
        (error, output) = procs.proc_cmd_output(cmd, func)
        if error == 0: # note: error is >0 exit value of cmd or <0 signal
            # Journal is already saved and backed up, return success.
            self.__out("Journal is already saved and backup is valid")
            return 0

        self.__out('Saving journal')

        cmd = "/usr/sbin/isi_save_journal -s %s --disarm" % serial
        (error, output) = procs.proc_cmd_output(cmd, func)
        if error == 1: # note: error is >0 exit value of cmd or <0 signal
            msg = ('WARNING: problems saving journal, '
                   'likely due to conditions changing before '
                   'the journal could be saved and validated.')
            self.__out(msg, **self.ERR_OPTS)
        elif error:
            self.__out('ERROR: unable to save journal', **self.ERR_OPTS)
            return error
        else:
            self.__out('Journal save complete')

        return 0

    def __set_unset_apply(self):
        """Applies any changes to the local node as a result of the set
           or unset bits specified.
           """
        # isi_boot_d is the only caller that uses the --apply option, The
        # read-only changes could have originated from any node, including
        # this one.
        old_read_only_bits = read_only_bits = self.args['apply']
        old_read_only = arrcfg.read_only_bool(old_read_only_bits)

        read_only_bits |= get_bits(self.args['set'])
        read_only_bits &= ~get_bits(self.args['unset'])
        read_only = arrcfg.read_only_bool(read_only_bits)

        # New read-only state will never have the system-shutdown bit set.
        # So, when called from isi_boot_d, we just need to save the journal
        # if we're going into read-only mode, otherwise we do nothing.
        if not old_read_only and read_only:
            return self.__save_journal()

    def __set_unset(self):
        """Sets or unsets bits for the requested node."""

        # We serialize setting/unsetting via an advisory lock
        # on this script, since we can otherwise end up in
        # situations where the changes made by one invocation
        # of this script get canceled out by the changes made
        # by another, nearly-simultaneous invocation. See bug 34030.
        lock = files.AdvisoryLock('/usr/bin/isi_hwtools/isi_read_only',
                                  os.O_RDONLY)
        try:
            error = 0
            lock.lockExclusive()
            try:
                (error, saveFunc) = self.__do_set_unset()
            finally:
                lock.unlock()
            if not error and saveFunc:
                error = saveFunc()
            return error
        finally:
            lock.close()

    def __wait_for_state(self, read_only_bits):
        """Waits until isi_boot_d confirms that the change to the
           given state has been made. I've actually seen this thing
           loop for a good 10 or 20 seconds on an otherwise completely
           idle system, so just imagine what it's like on a loaded system.
           Behold the power of isi_boot_d!
           """
        error = 0
        self.__out('Waiting for read-only config to settle')
        SLEEP_INTERVAL_SECS = 0.1
        maxLoops = 60 * 15 / SLEEP_INTERVAL_SECS # 15 minutes might be sufficient
        loop = 0
        while True:
            # Look up the current config 
            ac = arrcfg.config_load()
            node = ac.find_device_by_devid(ac.local_devid)

            # If it's what we're looking for, we're done!
            if node.read_only == read_only_bits:
                break

            # If not, then sleep for a moment to let things percolate
            time.sleep(SLEEP_INTERVAL_SECS)

            # Make sure we're not stuck doing this forever
            loop += 1
            if loop > maxLoops:
                msg = ("ERROR: isi_boot_d failed to update read-only mode "
                       "state after %d minutes" % (maxLoops / 60))
                self.__out(msg, **self.ERR_OPTS)
                msg = ("  current=%s" %
                       arrcfg.read_only_str(node.read_only))
                self.__out(msg, **self.EXT_ERR_OPTS)
                msg = ("  desired=%s" %
                       arrcfg.read_only_str(read_only_bits))
                self.__out(msg, **self.EXT_ERR_OPTS)
                error = 1
                break
        return error

    def __do_set_unset(self):
        """Does the hard work of setting or unsetting bits for the
           requested node.
           """
        error = 0
        saveFunc = None
        committed = False
        try:
            ac = arrcfg.config_load()
            node = ac.find_device_by_devid(ac.local_devid)
        except isi.error.IsiError, e:
            raise argslib.Error(str(e))

        if node.dev_type == arrcfg.DeviceType.diskless:
            raise argslib.Error(
                    'Cannot modify read-only state of a diskless node\n')

        read_only_bits = node.read_only
        old_read_only = node.read_only_as_bool

        try:
            if isi.misc.sysctlbyname('efs.gmp.shutdown_read_only') > 0:
                read_only_bits |= arrcfg.ARR_RO_READ_ONLY_SYSTEM_SHUTDOWN
                old_read_only = True
        except:
            pass

        old_read_only_bits = read_only_bits
        # Perform any requested set or unset actions
        read_only_bits |= get_bits(self.args['set'])
        read_only_bits &= ~get_bits(self.args['unset'])
        shutdown = read_only_bits & arrcfg.ARR_RO_READ_ONLY_SYSTEM_SHUTDOWN
        read_only = shutdown or (read_only_bits and not
                read_only_bits & arrcfg.ARR_RO_READ_ONLY_DISALLOW)
        # Save the journal if we're going INTO read-only mode.
        do_save = not old_read_only and read_only
        shutdown_on = (old_read_only_bits &
                arrcfg.ARR_RO_READ_ONLY_SYSTEM_SHUTDOWN)

        if old_read_only_bits != read_only_bits:
            read_only_bits &= ~arrcfg.ARR_RO_READ_ONLY_SYSTEM_SHUTDOWN
            old_read_only_bits &= ~arrcfg.ARR_RO_READ_ONLY_SYSTEM_SHUTDOWN

            # If the read_only bits have changed, excluding system-shutdown
            # which isn't saved in array.xml, tell isi_boot_d to update them
            if old_read_only_bits != read_only_bits:
                self.__out('Modifying read-only mode status for node %s' %
                        node.lnn)
                self.__out('  old: %-10s ( %s )' %
                        (read_only_string(old_read_only_bits),
                         arrcfg.read_only_str(old_read_only_bits)))
                self.__out('  new: %-10s ( %s )' %
                        (read_only_string(read_only_bits),
                         arrcfg.read_only_str(read_only_bits)))

                ac.set_device_read_only(node, read_only_bits)

                try:
                    arrcfg.config_commit(ac)
                    committed = True
                except isi.error.IsiError, e:
                    msg = ("Error: %s" % e)
                    self.__out(msg, **self.ERR_OPTS)
                    return (1, saveFunc)

            if shutdown or shutdown_on and not shutdown:
                if isi.misc.ifs_mounted():
                    error = isi.misc.shutdown_read_only(
                            shutdown and True or False)
                    if error:
                        return (error, saveFunc)
                else:
                    self.__out("OneFS is not mounted; "
                            "skipping shutdown-read-only syscall",
                            force=True)

            # Wait for the state change to appear in our local array.xml.
            error = self.__wait_for_state(read_only_bits)

        # If the node is shutdown or we've switched from read/write to
        # read-only but we didn't commit anything to isi_boot_d, we'll
        # want to save the journal once we've released the advisory lock
        # on isi_read_only. If we committed the read-only change to
        # isi_boot_d, he'll invoke this script in apply mode and we'll
        # save the journal then if necessary.
        if (shutdown and not shutdown_on) or (do_save and not committed):
            saveFunc = lambda : self.__save_journal()

        return (error, saveFunc)

    def __get(self):
        """Prints the current read-only settings.
        
        Reads the curent read-only configs for all nodes in the
        cluster and prints them out in a pretty table.
        """
    
        lnn_width = 4
        devid_width = 5
        value_width = 5
        mode_width = 12
        bits_width = 46
        divider = '-' * 78
    
        def print_line(lnn, devid, value, mode, bits, right_align=True):
            if right_align:
                mult = 1
            else:
                mult = -1
            msg = '%*s ' % (lnn_width*mult, lnn)
            if (self.args['verbose']):
                msg += '%*s %*s  ' % (devid_width*mult, devid,
                                      value_width*mult, value)
            else:
                msg += ' '
            msg += '%-*s  %-*s' % (mode_width, mode, bits_width, bits)
            print msg

        print_line('node', 'devid', 'value', 'mode', 'status',
                   right_align=False)
        print_line(lnn_width * '-', devid_width * '-', value_width * '-',
                   mode_width * '-', bits_width * '-')
     
        # Unfortunately we need to grab the shutdown status directly
        # from the GMP.
        try:
            import isi.fs.restripe.group as group
            gi = group.GroupInfo()
            gi.update()
            shutdown_list = gi.getShutdownReadOnlyNodeList()
        except:
            shutdown_list = []

        ac = arrcfg.config_load()
        for node in sorted(ac.devices, lambda x, y: cmp(x.lnn, y.lnn)):
            if node.dev_status == arrcfg.DeviceStatus.ok:
                ro_bits = node.read_only
                if node.devid in shutdown_list:
                    ro_bits |= arrcfg.ARR_RO_READ_ONLY_SYSTEM_SHUTDOWN

                print_line(node.lnn, node.devid, node.read_only,
                        read_only_string(ro_bits),
                        arrcfg.read_only_str(ro_bits) if ro_bits else '')

def read_only_bit_append(attrs, arg):
    """argslib func_attrs function that accepts a comma-delimited
       list of read-only bits and appends each of those bits to
       the _value field of the given attrs.
       """
    result = attrs['_value']
    bits = arg.split(',')
    
    # Make sure the given bit name is valid. If not, complain.
    for bit in bits:
        if bit == 'all':
            result += READ_ONLY_BITS.keys()
        elif bit in READ_ONLY_BITS:
            result += [bit]
        else:
            raise argslib.Error('Invalid read-only mode bit: %s' % bit)
    return result

def read_only_bits_list():
    usage_width = 80
    # Right pad width for balance and readability
    bitlist_width = usage_width - 10
    bitlists, bitlist = [], '  '
    for b in sorted(READ_ONLY_BITS.keys()):
        new = bitlist + b + ', '
        if len(new) < bitlist_width:
            bitlist = new
        else:
            bitlists.append(bitlist)
            bitlist = '  ' + b + ', '
    bitlists.append(bitlist.rstrip(', '))
    return bitlists

# Usage text
usage_text = """Usage: %%<app> [OPTIONS]

OPTIONS:
  --get                  Print current clusterwide read-only mode settings
  --set=<BITLIST>        Set the given read-only mode bits for the local node
  --unset=<BITLIST>      Unset the given read-only mode bits for the local node
  
  -h, --help             Show this text

<BITLIST>: Comma-separated list of one or more of:
%s

If no options are specified, "%%<app> --get" is assumed
""" % '\n'.join(read_only_bits_list())

# Command-line options
options = {
    'get':
    {'opts'       : ['get'],
     'default'    : False,
     'func'       : argslib.true_func(),
     'tallies'    : ['get'],
     },
    
    'set':
    {'opts'       : ['set='],
     'default'    : [],
     'func_attrs' : read_only_bit_append,
     'tallies'    : ['set', 'set/unset'],
     },

    'unset':
    {'opts'       : ['unset='],
     'default'    : [],
     'func_attrs' : read_only_bit_append,
     'tallies'    : ['unset', 'set/unset'],
     },

    'apply':
    {'opts'       : ['apply='],
     'default'    : None,
     'func'       : int,
     },

    'verbose':
    {'opts'       : ['v', 'verbose'],
     'default'    : False,
     'func'       : argslib.true_func(),
     },
    
    'help':
    {'opts'       : ['h', 'help'],
     'func'       : argslib.help_func(usage_text),
     },
    }

# Run module as a script when appropriate
argslib.script_main(main, usage_text, options)
