# Copyright 2011-2014 Splunk, 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.
from __future__ import absolute_import
# Absolute imports
from splunklib.client import Service
try:
from collections import OrderedDict # python 2.7
except ImportError:
from ordereddict import OrderedDict # python 2.6
from inspect import getmembers
from logging import _levelNames, getLevelName
from os import path
from sys import argv, exit, stdin, stdout
from urlparse import urlsplit
from xml.etree import ElementTree
# Relative imports
from . import csv, logging
from .decorators import Option
from .validators import Boolean, Fieldname
from .search_command_internals import InputHeader, MessagesHeader, \
SearchCommandParser
class SearchCommand(object):
""" Represents a custom search command.
"""
def __init__(self):
# Variables that may be used, but not altered by derived classes
self.logger, self._logging_configuration = logging.configure(
type(self).__name__)
self.input_header = InputHeader()
self.messages = MessagesHeader()
# Variables backing option/property values
self._default_logging_level = self.logger.level
self._configuration = None
self._fieldnames = None
self._option_view = None
self._search_results_info = None
self._service = None
self.parser = SearchCommandParser()
def __repr__(self):
return str(self)
def __str__(self):
values = [type(self).name, str(self.options)] + self.fieldnames
text = ' '.join([value for value in values if len(value) > 0])
return text
#region Options
@Option
def logging_configuration(self):
""" **Syntax:** logging_configuration=<path>
**Description:** Loads an alternative logging configuration file for
a command invocation. The logging configuration file must be in Python
ConfigParser-format. Path names are relative to the app root directory.
"""
return self._logging_configuration
@logging_configuration.setter
def logging_configuration(self, value):
self.logger, self._logging_configuration = logging.configure(
type(self).__name__, value)
return
@Option
def logging_level(self):
""" **Syntax:** logging_level=[CRITICAL|ERROR|WARNING|INFO|DEBUG|NOTSET]
**Description:** Sets the threshold for the logger of this command
invocation. Logging messages less severe than `logging_level` will be
ignored.
"""
return getLevelName(self.logger.getEffectiveLevel())
@logging_level.setter
def logging_level(self, value):
if value is None:
value = self._default_logging_level
if type(value) is str:
try:
level = _levelNames[value.upper()]
except KeyError:
raise ValueError('Unrecognized logging level: %s' % value)
else:
try:
level = int(value)
except ValueError:
raise ValueError('Unrecognized logging level: %s' % value)
self.logger.setLevel(level)
return
show_configuration = Option(doc='''
**Syntax:** show_configuration=<bool>
**Description:** When `true`, reports command configuration in the
messages header for this command invocation. Defaults to `false`.
''', default=False, validate=Boolean())
# #endregion
#region Properties
@property
def configuration(self):
""" Returns the configuration settings for this command.
"""
return self._configuration
@property
def fieldnames(self):
""" Returns the fieldnames specified as argument to this command.
"""
return self._fieldnames
@fieldnames.setter
def fieldnames(self, value):
self._fieldnames = value
@property
def options(self):
""" Returns the options specified as argument to this command.
"""
if self._option_view is None:
self._option_view = Option.View(self)
return self._option_view
@property
def search_results_info(self):
""" Returns the search results info for this command invocation or None.
The search results info object is created from the search results info
file associated with the command invocation. Splunk does not pass the
location of this file by default. You must request it by specifying
these configuration settings in commands.conf:
.. code-block:: python
enableheader=true
requires_srinfo=true
The :code:`enableheader` setting is :code:`true` by default. Hence, you
need not set it. The :code:`requires_srinfo` setting is false by
default. Hence, you must set it.
:return: :class:`SearchResultsInfo`, if :code:`enableheader` and
:code:`requires_srinfo` are both :code:`true`. Otherwise, if either
:code:`enableheader` or :code:`requires_srinfo` are :code:`false`,
a value of :code:`None` is returned.
"""
if self._search_results_info is not None:
return self._search_results_info
try:
info_path = self.input_header['infoPath']
except KeyError:
return None
def convert_field(field):
return (field[1:] if field[0] == '_' else field).replace('.', '_')
def convert_value(field, value):
if field == 'countMap':
split = value.split(';')
value = dict((key, int(value))
for key, value in zip(split[0::2], split[1::2]))
elif field == 'vix_families':
value = ElementTree.fromstring(value)
elif value == '':
value = None
else:
try:
value = float(value)
if value.is_integer():
value = int(value)
except ValueError:
pass
return value
with open(info_path, 'rb') as f:
from collections import namedtuple
import csv
reader = csv.reader(f, dialect='splunklib.searchcommands')
fields = [convert_field(x) for x in reader.next()]
values = [convert_value(f, v) for f, v in zip(fields, reader.next())]
search_results_info_type = namedtuple("SearchResultsInfo", fields)
self._search_results_info = search_results_info_type._make(values)
return self._search_results_info
@property
def service(self):
""" Returns a Splunk service object for this command invocation or None.
The service object is created from the Splunkd URI and authentication
token passed to the command invocation in the search results info file.
This data is not passed to a command invocation by default. You must
request it by specifying this pair of configuration settings in
commands.conf:
.. code-block:: python
enableheader=true
requires_srinfo=true
The :code:`enableheader` setting is :code:`true` by default. Hence, you
need not set it. The :code:`requires_srinfo` setting is false by
default. Hence, you must set it.
:return: :class:`splunklib.client.Service`, if :code:`enableheader` and
:code:`requires_srinfo` are both :code:`true`. Otherwise, if either
:code:`enableheader` or :code:`requires_srinfo` are :code:`false`,
a value of :code:`None` is returned.
"""
if self._service is not None:
return self._service
info = self.search_results_info
if info is None:
return None
_, netloc, _, _, _ = urlsplit(
info.splunkd_uri, info.splunkd_protocol, allow_fragments=False)
splunkd_host, _ = netloc.split(':')
self._service = Service(
scheme=info.splunkd_protocol, host=splunkd_host,
port=info.splunkd_port, token=info.auth_token,
app=info.ppc_app)
return self._service
#endregion
#region Methods
def process(self, args=argv, input_file=stdin, output_file=stdout):
""" Processes search results as specified by command arguments.
:param args: Sequence of command arguments
:param input_file: Pipeline input file
:param output_file: Pipeline output file
"""
self.logger.debug('%s arguments: %s' % (type(self).__name__, args))
self._configuration = None
try:
if len(args) >= 2 and args[1] == '__GETINFO__':
ConfigurationSettings, operation, args, reader = self._prepare(
args, input_file=None)
self.parser.parse(args, self)
self._configuration = ConfigurationSettings(self)
writer = csv.DictWriter(
output_file, self, self.configuration.keys(), mv_delimiter=',')
writer.writerow(self.configuration.items())
elif len(args) >= 2 and args[1] == '__EXECUTE__':
self.input_header.read(input_file)
ConfigurationSettings, operation, args, reader = self._prepare(
args, input_file)
self.parser.parse(args, self)
self._configuration = ConfigurationSettings(self)
if self.show_configuration:
self.messages.append(
'info_message', '%s command configuration settings: %s'
% (self.name, self._configuration))
writer = csv.DictWriter(output_file, self)
self._execute(operation, reader, writer)
else:
file_name = path.basename(args[0])
message = (
'Command {0} appears to be statically configured and static '
'configuration is unsupported by splunklib.searchcommands. '
'Please ensure that default/commands.conf contains this '
'stanza: '
'[{0}] | '
'filename = {1} | '
'supports_getinfo = true | '
'supports_rawargs = true | '
'outputheader = true'.format(type(self).name, file_name))
raise NotImplementedError(message)
except Exception as error:
from traceback import format_exc
writer = csv.DictWriter(output_file, self, fieldnames=['ERROR'])
writer.writerow({'ERROR': error})
self.logger.error(format_exc())
exit(1)
return
@staticmethod
def records(reader):
for record in reader:
yield record
return
def _prepare(self, argv, input_file):
raise NotImplementedError('SearchCommand._configure(self, argv)')
def _execute(self, operation, reader, writer):
raise NotImplementedError('SearchCommand._configure(self, argv)')
#endregion
#region Types
class ConfigurationSettings(object):
""" Represents the configuration settings common to all
:class:`SearchCommand` classes.
"""
def __init__(self, command):
self.command = command
def __str__(self):
""" Converts the value of this instance to its string representation.
The value of this ConfigurationSettings instance is represented as a
string of newline-separated :code:`name=value` pairs.
:return: String representation of this instance
"""
text = ', '.join(
['%s=%s' % (k, getattr(self, k)) for k in self.keys()])
return text
#region Properties
# Constant configuration settings
@property
def changes_colorder(self):
""" Specifies whether output should be used to change the column
ordering of fields.
Default: :const:`True`
"""
return type(self)._changes_colorder
_changes_colorder = True
@property
def clear_required_fields(self):
""" Specifies whether `required_fields` are the only fields required
by subsequent commands.
If :const:`True`, :attr:`required_fields` are the *only* fields
required by subsequent commands. If :const:`False`,
:attr:`required_fields` are additive to any fields that may be
required by subsequent commands. In most cases :const:`False` is
appropriate for streaming commands and :const:`True` is appropriate
for reporting commands.
Default: :const:`False`
"""
return type(self)._clear_required_fields
_clear_required_fields = False
@property
def enableheader(self):
""" Signals that this command expects header information.
Fixed: :const:`True`
"""
return True
@property
def generating(self):
""" Signals that this command does not generate new events.
Fixed: :const:`False`
"""
return False
@property
def maxinputs(self):
""" Specifies the maximum number of events that may be passed to an
invocation of this command.
This limit may not exceed the value of `maxresultrows` as defined in
limits.conf (default: 50,000). Use a value of zero (0) to select a
limit of `maxresultrows`.
Default: :code:`0`
"""
return type(self)._maxinputs
_maxinputs = 0
@property
def needs_empty_results(self):
""" Specifies whether or not this search command must be called with
intermediate empty search results.
Default: :const:`True`
"""
return type(self)._needs_empty_results
_needs_empty_results = True
@property
def outputheader(self):
""" Signals that the output of this command is a messages header
followed by a blank line and csv search results.
Fixed: :const:`True`
"""
return True
@property
def passauth(self):
""" Specifies whether or not this search command requires an
authentication token on the start of input.
Default: :const:`False`
"""
return type(self)._passauth
_passauth = False
@property
def perf_warn_limit(self):
""" Tells Splunk to issue a performance warning message if more
than this many input events are passed to this search command.
A value of zero (0) disables performance warning messages.
Default: :code:`0`
"""
return type(self)._perf_warn_limit
_perf_warn_limit = 0
@property
def requires_srinfo(self):
""" Specifies whether or not this command requires search results
information.
If :const:`True` the full path to a search results information file
is provided by :attr:`SearchCommand.input_header['infoPath']`.
Default: :const:`False`
"""
return type(self)._requires_srinfo
_requires_srinfo = False
@property
def run_in_preview(self):
""" Tells Splunk whether to run this command when generating results
for preview rather than final output.
Default: :const:`True`
"""
return type(self)._run_in_preview
_run_in_preview = True
@property
def stderr_dest(self):
""" Tells Splunk what to do with messages logged to `stderr`.
Specify one of these string values:
================== ========================================================
Value Meaning
================== ========================================================
:code:`'log'` Write messages to the job's search.log file
:code:`'message'` Write each line of each message as a search info message
:code:`'none'` Discard all messages logged to stderr
================== ========================================================
Default: :code:`'log'`
"""
return type(self)._stderr_dest
_stderr_dest = 'log'
@property
def supports_multivalues(self):
""" Signals that this search command supports multivalues.
Fixed: :const:`True`
"""
return True
@property
def supports_rawargs(self):
""" Signals that this search command parses raw arguments.
Fixed: :const:`True`
"""
return True
# Computed configuration settings
@property
def required_fields(self):
""" Specifies a comma-separated list of required field names.
This list is computed as the union of the set of fieldnames and
fieldname-valued options given as argument to this command.
"""
fieldnames = set(self.command.fieldnames)
for name, option in self.command.options.iteritems():
if isinstance(option.validator, Fieldname):
value = option.value
if value is not None:
fieldnames.add(value)
text = ','.join(fieldnames)
return text
#endregion
#region Methods
@classmethod
def configuration_settings(cls):
""" Represents this class as a dictionary of :class:`property`
instances and :code:`backing_field` names keyed by configuration
setting name.
This method is used by the :class:`ConfigurationSettingsType`
meta-class to construct new :class:`ConfigurationSettings` classes.
It is also used by instances of this class to retrieve configuration
setting names and their values. See :meth:`SearchCommand.keys` and
:meth:`SearchCommand.items`.
"""
if cls._settings is None:
is_property = lambda x: isinstance(x, property)
cls._settings = {}
for name, prop in getmembers(cls, is_property):
backing_field = '_' + name
if not hasattr(cls, backing_field):
backing_field = None
cls._settings[name] = (prop, backing_field)
return cls._settings
@classmethod
def fix_up(cls, command_class):
""" Adjusts and checks this class and its search command class.
Derived classes must override this method. It is used by the
:decorator:`Configuration` decorator to fix up the
:class:`SearchCommand` classes it adorns. This method is overridden
by :class:`GeneratingCommand`, :class:`ReportingCommand`, and
:class:`StreamingCommand`, the base types for all other search
commands.
:param command_class: Command class targeted by this class
"""
raise NotImplementedError(
'SearchCommand.fix_up method must be overridden')
def items(self):
""" Represents this instance as an :class:`OrderedDict`.
This method is used by the SearchCommand.process method to report
configuration settings to Splunk during the :code:`__GETINFO__`
phase of a request to process a chunk of search results.
:return: :class:`OrderedDict` containing setting values keyed by
name
"""
return OrderedDict([(k, getattr(self, k)) for k in self.keys()])
def keys(self):
""" Gets the names of the settings represented by this instance.
:return: Sorted list of setting names.
"""
return sorted(type(self).configuration_settings().keys())
#endregion
#region Variables
_settings = None
#endregion
#endregion