You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
592 lines
22 KiB
592 lines
22 KiB
#
|
|
# Copyright 2025 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.
|
|
#
|
|
# encoding = utf-8
|
|
import copy
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import urllib
|
|
|
|
from solnlib import utils as sutils
|
|
from solnlib.log import Logs
|
|
from solnlib.modular_input import checkpointer
|
|
from splunklib import modularinput as smi
|
|
|
|
from splunktaucclib.global_config import GlobalConfig, GlobalConfigSchema
|
|
from splunktaucclib.splunk_aoblib.rest_helper import TARestHelper
|
|
from splunktaucclib.splunk_aoblib.setup_util import Setup_Util
|
|
|
|
DATA_INPUTS_OPTIONS = "data_inputs_options"
|
|
AOB_TEST_FLAG = "AOB_TEST"
|
|
FIELD_TYPE = "type"
|
|
FIELD_FORMAT = "format_type"
|
|
CUSTOMIZED_VAR = "customized_var"
|
|
TYPE_CHECKBOX = "checkbox"
|
|
TYPE_ACCOUNT = "global_account"
|
|
|
|
|
|
class BaseModInput(smi.Script):
|
|
"""
|
|
This is a modular input wrapper, which provides some helper
|
|
functions to read the paramters from setup pages and the arguments
|
|
from input definition
|
|
"""
|
|
|
|
LogLevelMapping = {
|
|
"debug": logging.DEBUG,
|
|
"info": logging.INFO,
|
|
"warning": logging.WARNING,
|
|
"error": logging.ERROR,
|
|
"critical": logging.CRITICAL,
|
|
}
|
|
|
|
def __init__(self, app_namespace, input_name, use_single_instance=False):
|
|
super().__init__()
|
|
self.use_single_instance = use_single_instance
|
|
self._canceled = False
|
|
self.input_type = input_name
|
|
self.input_stanzas = {}
|
|
self.context_meta = {}
|
|
self.namespace = app_namespace
|
|
# redirect all the logging to one file
|
|
Logs.set_context(namespace=app_namespace, root_logger_log_file=input_name)
|
|
self.logger = logging.getLogger()
|
|
self.logger.setLevel(logging.INFO)
|
|
self.rest_helper = TARestHelper(self.logger)
|
|
# check point
|
|
self.ckpt = None
|
|
self.setup_util = None
|
|
|
|
@property
|
|
def app(self):
|
|
return self.get_app_name()
|
|
|
|
@property
|
|
def global_setup_util(self):
|
|
"""
|
|
This is a private API used in AoB code internally. It is not allowed to be used in user's code.
|
|
:return: setup util instance to read global configurations
|
|
"""
|
|
return self.setup_util
|
|
|
|
def get_app_name(self):
|
|
"""Get TA name.
|
|
:return: the name of TA this modular input is in
|
|
"""
|
|
raise NotImplemented
|
|
|
|
def get_scheme(self):
|
|
"""Get basic scheme, with use_single_instance field set.
|
|
:return: a basic input scheme
|
|
"""
|
|
scheme = smi.Scheme(self.input_type)
|
|
scheme.use_single_instance = self.use_single_instance
|
|
return scheme
|
|
|
|
def stream_events(self, inputs, ew):
|
|
"""The method called to stream events into Splunk.
|
|
This method overrides method in splunklib modular input.
|
|
It pre-processes the input args and call collect_events to stream events.
|
|
:param inputs: An ``InputDefinition`` object.
|
|
:param ew: An object with methods to write events and log messages to Splunk.
|
|
"""
|
|
# the input metadata is like
|
|
# {
|
|
# 'server_uri': 'https://127.0.0.1:8089',
|
|
# 'server_host': 'localhost',
|
|
# 'checkpoint_dir': '...',
|
|
# 'session_key': 'ceAvf3z^hZHYxe7wjTyTNo6_0ZRpf5cvWPdtSg'
|
|
# }
|
|
self.context_meta = inputs.metadata
|
|
# init setup util
|
|
uri = inputs.metadata["server_uri"]
|
|
session_key = inputs.metadata["session_key"]
|
|
self.setup_util = Setup_Util(uri, session_key, self.logger)
|
|
|
|
input_definition = smi.input_definition.InputDefinition()
|
|
input_definition.metadata = copy.deepcopy(inputs.metadata)
|
|
input_definition.inputs = copy.deepcopy(inputs.inputs)
|
|
try:
|
|
self.parse_input_args(input_definition)
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
self.log_error(traceback.format_exc())
|
|
print(traceback.format_exc(), file=sys.stderr)
|
|
# print >> sys.stderr, traceback.format_exc()
|
|
self.input_stanzas = {}
|
|
if not self.input_stanzas:
|
|
# if no stanza found. Just return
|
|
return
|
|
try:
|
|
self.set_log_level(self.log_level)
|
|
except:
|
|
self.log_debug("set log level fails.")
|
|
try:
|
|
self.collect_events(ew)
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
self.log_error(
|
|
"Get error when collecting events.\n" + traceback.format_exc()
|
|
)
|
|
print(traceback.format_exc(), file=sys.stderr)
|
|
# print >> sys.stderr, traceback.format_exc()
|
|
raise RuntimeError(str(e))
|
|
|
|
def collect_events(self, event_writer):
|
|
"""Collect events and stream to Splunk using event writer provided.
|
|
Note: This method is originally collect_events(self, inputs, event_writer).
|
|
:param event_writer: An object with methods to write events and log messages to Splunk.
|
|
"""
|
|
raise NotImplemented()
|
|
|
|
def parse_input_args(self, inputs):
|
|
"""Parse input arguments, either from os environment when testing or from global configuration.
|
|
:param inputs: An ``InputDefinition`` object.
|
|
:return:
|
|
"""
|
|
if os.environ.get(AOB_TEST_FLAG, "false") == "true":
|
|
self._parse_input_args_from_env(inputs)
|
|
else:
|
|
self._parse_input_args_from_global_config(inputs)
|
|
if not self.use_single_instance:
|
|
assert len(self.input_stanzas) == 1
|
|
|
|
def _parse_input_args_from_global_config(self, inputs):
|
|
"""Parse input arguments from global configuration.
|
|
:param inputs:
|
|
"""
|
|
# dirname at this point will be <splunk_home>/etc/apps/<ta-name>/lib/splunktaucclib/modinput_wrapper, go up 3 dirs from this file to find the root TA directory
|
|
dirname = os.path.dirname
|
|
config_path = os.path.join(
|
|
dirname(dirname(dirname(dirname(__file__)))),
|
|
"appserver",
|
|
"static",
|
|
"js",
|
|
"build",
|
|
"globalConfig.json",
|
|
)
|
|
with open(config_path) as f:
|
|
schema_json = "".join([l for l in f])
|
|
global_schema = GlobalConfigSchema(json.loads(schema_json))
|
|
|
|
uri = inputs.metadata["server_uri"]
|
|
session_key = inputs.metadata["session_key"]
|
|
global_config = GlobalConfig(uri, session_key, global_schema)
|
|
ucc_inputs = global_config.inputs.load(input_type=self.input_type)
|
|
all_stanzas = ucc_inputs.get(self.input_type, {})
|
|
if not all_stanzas:
|
|
# for single instance input. There might be no input stanza.
|
|
# Only the default stanza. In this case, modinput should exit.
|
|
self.log_warning("No stanza found for input type: " + self.input_type)
|
|
sys.exit(0)
|
|
|
|
account_fields = self.get_account_fields()
|
|
checkbox_fields = self.get_checkbox_fields()
|
|
self.input_stanzas = {}
|
|
for stanza in all_stanzas:
|
|
full_stanza_name = "{}://{}".format(self.input_type, stanza.get("name"))
|
|
if full_stanza_name in inputs.inputs:
|
|
if stanza.get("disabled", False):
|
|
raise RuntimeError("Running disabled data input!")
|
|
stanza_params = {}
|
|
for k, v in stanza.items():
|
|
if k in checkbox_fields:
|
|
stanza_params[k] = sutils.is_true(v)
|
|
elif k in account_fields:
|
|
stanza_params[k] = copy.deepcopy(v)
|
|
else:
|
|
stanza_params[k] = v
|
|
self.input_stanzas[stanza.get("name")] = stanza_params
|
|
|
|
def _parse_input_args_from_env(self, inputs):
|
|
"""Parse input arguments from os environment. This is used for testing inputs.
|
|
:param inputs:
|
|
"""
|
|
data_inputs_options = json.loads(os.environ.get(DATA_INPUTS_OPTIONS, "[]"))
|
|
account_fields = self.get_account_fields()
|
|
checkbox_fields = self.get_checkbox_fields()
|
|
self.input_stanzas = {}
|
|
while len(inputs.inputs) > 0:
|
|
input_stanza, stanza_args = inputs.inputs.popitem()
|
|
kind_and_name = input_stanza.split("://")
|
|
if len(kind_and_name) == 2:
|
|
stanza_params = {}
|
|
for arg_name, arg_value in stanza_args.items():
|
|
try:
|
|
arg_value_trans = json.loads(arg_value)
|
|
except ValueError:
|
|
arg_value_trans = arg_value
|
|
stanza_params[arg_name] = arg_value_trans
|
|
if arg_name in account_fields:
|
|
stanza_params[arg_name] = self.get_user_credential_by_id(
|
|
arg_value_trans
|
|
)
|
|
elif arg_name in checkbox_fields:
|
|
stanza_params[arg_name] = sutils.is_true(arg_value_trans)
|
|
self.input_stanzas[kind_and_name[1]] = stanza_params
|
|
|
|
def get_account_fields(self):
|
|
"""Get the names of account variables.
|
|
Should be implemented in subclass.
|
|
:return: a list of variable names
|
|
"""
|
|
raise NotImplemented
|
|
|
|
def get_checkbox_fields(self):
|
|
"""Get the names of checkbox variables.
|
|
Should be implemented in subclass.
|
|
:return: a list of variable names
|
|
"""
|
|
raise NotImplemented
|
|
|
|
def get_global_checkbox_fields(self):
|
|
"""Get the names of checkbox global parameters.
|
|
:return: a list of global variable names
|
|
"""
|
|
raise NotImplemented
|
|
|
|
# Global setting related functions.
|
|
# Global settings consist of log setting, proxy, account(user_credential) and customized settings.
|
|
@property
|
|
def log_level(self):
|
|
return self.get_log_level()
|
|
|
|
def get_log_level(self):
|
|
"""Get the log level configured in global configuration.
|
|
:return: log level set in global configuration or "INFO" by default.
|
|
"""
|
|
return self.setup_util.get_log_level()
|
|
|
|
def set_log_level(self, level):
|
|
"""Set the log level this python process uses.
|
|
:param level: log level in `string`. Accept "DEBUG", "INFO", "WARNING", "ERROR" and "CRITICAL".
|
|
"""
|
|
if isinstance(level, str):
|
|
level = level.lower()
|
|
if level in self.LogLevelMapping:
|
|
level = self.LogLevelMapping[level]
|
|
else:
|
|
level = logging.INFO
|
|
self.logger.setLevel(level)
|
|
|
|
def log(self, msg):
|
|
"""Log msg using logging level in global configuration.
|
|
:param msg: log `string`
|
|
"""
|
|
self.logger.log(level=self.log_level, msg=msg)
|
|
|
|
def log_debug(self, msg):
|
|
"""Log msg using logging.DEBUG level.
|
|
:param msg: log `string`
|
|
"""
|
|
self.logger.debug(msg)
|
|
|
|
def log_info(self, msg):
|
|
"""Log msg using logging.INFO level.
|
|
:param msg: log `string`
|
|
"""
|
|
self.logger.info(msg)
|
|
|
|
def log_warning(self, msg):
|
|
"""Log msg using logging.WARNING level.
|
|
:param msg: log `string`
|
|
"""
|
|
self.logger.warning(msg)
|
|
|
|
def log_error(self, msg):
|
|
"""Log msg using logging.ERROR level.
|
|
:param msg: log `string`
|
|
"""
|
|
self.logger.error(msg)
|
|
|
|
def log_critical(self, msg):
|
|
"""Log msg using logging.CRITICAL level.
|
|
:param msg: log `string`
|
|
"""
|
|
self.logger.critical(msg)
|
|
|
|
@property
|
|
def proxy(self):
|
|
return self.get_proxy()
|
|
|
|
def get_proxy(self):
|
|
"""Get proxy settings in global configuration.
|
|
Proxy settings include fields "proxy_url", "proxy_port", "proxy_username", "proxy_password", "proxy_type" and "proxy_rdns".
|
|
:return: a `dict` containing proxy parameters or empty `dict` if proxy is not set.
|
|
"""
|
|
return self.setup_util.get_proxy_settings()
|
|
|
|
def get_user_credential_by_username(self, username):
|
|
"""Get global credential information based on username.
|
|
Credential settings include fields "name"(account id), "username" and "password".
|
|
:param username: `string`
|
|
:return: if credential with username exists, return a `dict`, else None.
|
|
"""
|
|
return self.setup_util.get_credential_by_username(username)
|
|
|
|
def get_user_credential_by_id(self, account_id):
|
|
"""Get global credential information based on account id.
|
|
Credential settings include fields "name"(account id), "username" and "password".
|
|
:param account_id: `string`
|
|
:return: if credential with account_id exists, return a `dict`, else None.
|
|
"""
|
|
return self.setup_util.get_credential_by_id(account_id)
|
|
|
|
def get_global_setting(self, var_name):
|
|
"""Get customized setting value configured in global configuration.
|
|
:param var_name: `string`
|
|
:return: customized global configuration value or None
|
|
"""
|
|
var_value = self.setup_util.get_customized_setting(var_name)
|
|
if var_value is not None and var_name in self.get_global_checkbox_fields():
|
|
var_value = sutils.is_true(var_value)
|
|
return var_value
|
|
|
|
# Functions to help create events.
|
|
def new_event(
|
|
self,
|
|
data,
|
|
time=None,
|
|
host=None,
|
|
index=None,
|
|
source=None,
|
|
sourcetype=None,
|
|
done=True,
|
|
unbroken=True,
|
|
):
|
|
"""Create a Splunk event object.
|
|
:param data: ``string``, the event's text.
|
|
:param time: ``float``, time in seconds, including up to 3 decimal places to represent milliseconds.
|
|
:param host: ``string``, the event's host, ex: localhost.
|
|
:param index: ``string``, the index this event is specified to write to, or None if default index.
|
|
:param source: ``string``, the source of this event, or None to have Splunk guess.
|
|
:param sourcetype: ``string``, source type currently set on this event, or None to have Splunk guess.
|
|
:param done: ``boolean``, is this a complete ``Event``? False if an ``Event`` fragment.
|
|
:param unbroken: ``boolean``, Is this event completely encapsulated in this ``Event`` object?
|
|
:return: ``Event`` object
|
|
"""
|
|
return smi.Event(
|
|
data=data,
|
|
time=time,
|
|
host=host,
|
|
index=index,
|
|
source=source,
|
|
sourcetype=sourcetype,
|
|
done=done,
|
|
unbroken=unbroken,
|
|
)
|
|
|
|
# Basic get functions. To get params in input stanza.
|
|
def get_input_type(self):
|
|
"""Get input type.
|
|
:return: the modular input type
|
|
"""
|
|
return self.input_type
|
|
|
|
def get_input_stanza(self, input_stanza_name=None):
|
|
"""Get input stanzas.
|
|
If stanza name is None, return a dict with stanza name as key and params as values.
|
|
Else return a dict with param name as key and param value as value.
|
|
:param input_stanza_name: None or `string`
|
|
:return: `dict`
|
|
"""
|
|
if input_stanza_name:
|
|
return self.input_stanzas.get(input_stanza_name, None)
|
|
return self.input_stanzas
|
|
|
|
def get_input_stanza_names(self):
|
|
"""Get all stanza names this modular input instance is given.
|
|
For multi instance mode, a single string value will be returned.
|
|
For single instance mode, stanza names will be returned in a list.
|
|
:return: `string` or `list`
|
|
"""
|
|
if self.input_stanzas:
|
|
names = list(self.input_stanzas.keys())
|
|
if self.use_single_instance:
|
|
return names
|
|
else:
|
|
assert len(names) == 1
|
|
return names[0]
|
|
return None
|
|
|
|
def get_arg(self, arg_name, input_stanza_name=None):
|
|
"""Get the input argument.
|
|
If input_stanza_name is not provided:
|
|
For single instance mode, return a dict <input_name, arg_value>.
|
|
For multi instance mode, return a single value or None.
|
|
If input_stanza_name is provided, return a single value or None.
|
|
:param arg_name: `string`, argument name
|
|
:param input_stanza_name: None or `string`, a stanza name
|
|
:return: `dict` or `string` or None
|
|
"""
|
|
if input_stanza_name is None:
|
|
args_dict = {
|
|
k: args[arg_name]
|
|
for k, args in self.input_stanzas.items()
|
|
if arg_name in args
|
|
}
|
|
if self.use_single_instance:
|
|
return args_dict
|
|
else:
|
|
if len(args_dict) == 1:
|
|
return list(args_dict.values())[0]
|
|
return None
|
|
else:
|
|
return self.input_stanzas.get(input_stanza_name, {}).get(arg_name, None)
|
|
|
|
def get_output_index(self, input_stanza_name=None):
|
|
"""Get output Splunk index.
|
|
:param input_stanza_name: `string`
|
|
:return: `string` output index
|
|
"""
|
|
return self.get_arg("index", input_stanza_name)
|
|
|
|
def get_sourcetype(self, input_stanza_name=None):
|
|
"""Get sourcetype to index.
|
|
:param input_stanza_name: `string`
|
|
:return: the sourcetype to index to
|
|
"""
|
|
return self.get_arg("sourcetype", input_stanza_name)
|
|
|
|
# HTTP request helper
|
|
def send_http_request(
|
|
self,
|
|
url,
|
|
method,
|
|
parameters=None,
|
|
payload=None,
|
|
headers=None,
|
|
cookies=None,
|
|
verify=True,
|
|
cert=None,
|
|
timeout=None,
|
|
use_proxy=True,
|
|
):
|
|
"""Send http request and get response.
|
|
:param url: URL for the new Request object.
|
|
:param method: method for the new Request object. Can be "GET", "POST", "PUT", "DELETE"
|
|
:param parameters: (optional) Dictionary or bytes to be sent in the query string for the Request.
|
|
:param payload: (optional) Dictionary, bytes, or file-like object to send in the body of the Request.
|
|
:param headers: (optional) Dictionary of HTTP Headers to send with the Request.
|
|
:param cookies: (optional) Dict or CookieJar object to send with the Request.
|
|
:param verify: (optional) whether the SSL cert will be verified. A CA_BUNDLE path can also be provided.
|
|
:param cert: (optional) if String, path to ssl client cert file (.pem). If Tuple, ('cert', 'key') pair.
|
|
:param timeout: (optional) How long to wait for the server to send data before giving up, as a float,
|
|
or a (connect timeout, read timeout) tuple. Default to (10.0, 5.0).
|
|
:param use_proxy: (optional) whether to use proxy. If set to True, proxy in global setting will be used.
|
|
:return: Response
|
|
"""
|
|
return self.rest_helper.send_http_request(
|
|
url=url,
|
|
method=method,
|
|
parameters=parameters,
|
|
payload=payload,
|
|
headers=headers,
|
|
cookies=cookies,
|
|
verify=verify,
|
|
cert=cert,
|
|
timeout=timeout,
|
|
proxy_uri=self._get_proxy_uri() if use_proxy else None,
|
|
)
|
|
|
|
def _get_proxy_uri(self):
|
|
uri = None
|
|
proxy = self.get_proxy()
|
|
if proxy and proxy.get("proxy_url") and proxy.get("proxy_type"):
|
|
uri = proxy["proxy_url"]
|
|
if proxy.get("proxy_port"):
|
|
uri = "{}:{}".format(uri, proxy.get("proxy_port"))
|
|
if proxy.get("proxy_username") and proxy.get("proxy_password"):
|
|
proxy["proxy_username"] = urllib.parse.quote_plus(
|
|
proxy["proxy_username"]
|
|
)
|
|
proxy["proxy_password"] = urllib.parse.quote_plus(
|
|
proxy["proxy_password"]
|
|
)
|
|
|
|
uri = "{}://{}:{}@{}/".format(
|
|
proxy["proxy_type"],
|
|
proxy["proxy_username"],
|
|
proxy["proxy_password"],
|
|
uri,
|
|
)
|
|
else:
|
|
uri = "{}://{}".format(proxy["proxy_type"], uri)
|
|
return uri
|
|
|
|
# Checkpointing related functions
|
|
def _init_ckpt(self):
|
|
if self.ckpt is None:
|
|
if "AOB_TEST" in os.environ:
|
|
ckpt_dir = self.context_meta.get("checkpoint_dir", tempfile.mkdtemp())
|
|
if not os.path.exists(ckpt_dir):
|
|
os.makedirs(ckpt_dir)
|
|
self.ckpt = checkpointer.FileCheckpointer(ckpt_dir)
|
|
else:
|
|
if "server_uri" not in self.context_meta:
|
|
raise ValueError("server_uri not found in input meta.")
|
|
if "session_key" not in self.context_meta:
|
|
raise ValueError("session_key not found in input meta.")
|
|
dscheme, dhost, dport = sutils.extract_http_scheme_host_port(
|
|
self.context_meta["server_uri"]
|
|
)
|
|
self.ckpt = checkpointer.KVStoreCheckpointer(
|
|
self.app + "_checkpointer",
|
|
self.context_meta["session_key"],
|
|
self.app,
|
|
scheme=dscheme,
|
|
host=dhost,
|
|
port=dport,
|
|
)
|
|
|
|
def get_check_point(self, key):
|
|
"""Get checkpoint.
|
|
:param key: `string`
|
|
:return: Checkpoint state if exists else None.
|
|
"""
|
|
if self.ckpt is None:
|
|
self._init_ckpt()
|
|
return self.ckpt.get(key)
|
|
|
|
def save_check_point(self, key, state):
|
|
"""Update checkpoint.
|
|
:param key: Checkpoint key. `string`
|
|
:param state: Checkpoint state.
|
|
"""
|
|
if self.ckpt is None:
|
|
self._init_ckpt()
|
|
self.ckpt.update(key, state)
|
|
|
|
def batch_save_check_point(self, states):
|
|
"""Batch update checkpoint.
|
|
:param states: a `dict` states with checkpoint key as key and checkpoint state as value.
|
|
"""
|
|
if self.ckpt is None:
|
|
self._init_ckpt()
|
|
self.ckpt.batch_update(states)
|
|
|
|
def delete_check_point(self, key):
|
|
"""Delete checkpoint.
|
|
:param key: Checkpoint key. `string`
|
|
"""
|
|
if self.ckpt is None:
|
|
self._init_ckpt()
|
|
self.ckpt.delete(key)
|