962 lines
38 KiB
Python
962 lines
38 KiB
Python
# (C) Copyright 2015,2017-2018 Hewlett Packard Enterprise Development LP
|
|
# (C) Copyright 2017 KylinCloud
|
|
# Copyright 2018 OP5 AB
|
|
# 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 base64
|
|
import json
|
|
import logging
|
|
import math
|
|
from numbers import Number
|
|
import os
|
|
import re
|
|
import requests
|
|
|
|
from monasca_agent.common import exceptions
|
|
from monasca_agent.common import keystone
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
DEFAULT_TIMEOUT = 20
|
|
|
|
|
|
def add_basic_auth(request, username, password):
|
|
"""A helper to add basic authentication to a urllib2 request.
|
|
|
|
We do this across a variety of checks so it's good to have this in one place.
|
|
"""
|
|
auth_str = base64.encodestring('%s:%s' % (username, password)).strip()
|
|
request.add_header('Authorization', 'Basic %s' % auth_str)
|
|
return request
|
|
|
|
|
|
def get_tenant_name(tenants, tenant_id):
|
|
tenant_name = None
|
|
for tenant in tenants:
|
|
if tenant.id == tenant_id:
|
|
tenant_name = tenant.name
|
|
break
|
|
return tenant_name
|
|
|
|
|
|
def get_tenant_list(config, log):
|
|
tenants = []
|
|
try:
|
|
log.debug("Retrieving Keystone tenant list")
|
|
client = keystone.get_client(**config)
|
|
if 'v2' in client.__module__:
|
|
tenants = client.tenants.list()
|
|
else:
|
|
tenants = client.projects.list()
|
|
except Exception as e:
|
|
msg = "Unable to get tenant list from keystone: {0}"
|
|
log.error(msg.format(e))
|
|
|
|
return tenants
|
|
|
|
|
|
def convert_memory_string_to_bytes(memory_string):
|
|
"""Conversion from memory represented in string format to bytes"""
|
|
if "m" in memory_string:
|
|
memory = float(memory_string.split('m')[0])
|
|
return memory / 1000
|
|
elif "K" in memory_string:
|
|
memory = float(memory_string.split('K')[0])
|
|
return _compute_memory_bytes(memory_string, memory, 1)
|
|
elif "M" in memory_string:
|
|
memory = float(memory_string.split('M')[0])
|
|
return _compute_memory_bytes(memory_string, memory, 2)
|
|
elif "G" in memory_string:
|
|
memory = float(memory_string.split('G')[0])
|
|
return _compute_memory_bytes(memory_string, memory, 3)
|
|
elif "T" in memory_string:
|
|
memory = float(memory_string.split('T')[0])
|
|
return _compute_memory_bytes(memory_string, memory, 4)
|
|
else:
|
|
return float(memory_string)
|
|
|
|
|
|
def _compute_memory_bytes(memory_string, memory, power):
|
|
if "i" in memory_string:
|
|
return memory * math.pow(1024, power)
|
|
return memory * math.pow(1000, power)
|
|
|
|
|
|
class KubernetesConnector(object):
|
|
"""Class for connecting to Kubernetes API from within a container running
|
|
in a Kubernetes environment
|
|
"""
|
|
CACERT_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt'
|
|
TOKEN_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/token' # nosec B105
|
|
|
|
def __init__(self, connection_timeout):
|
|
self.api_url = None
|
|
self.api_verify = None
|
|
self.api_request_header = None
|
|
if connection_timeout is None:
|
|
self.connection_timeout = DEFAULT_TIMEOUT
|
|
else:
|
|
self.connection_timeout = connection_timeout
|
|
self._set_kubernetes_api_connection_info()
|
|
self._set_kubernetes_service_account_info()
|
|
|
|
def _set_kubernetes_api_connection_info(self):
|
|
"""Set kubernetes API string from default container environment
|
|
variables
|
|
"""
|
|
api_host = os.environ.get('KUBERNETES_SERVICE_HOST', "kubernetes.default")
|
|
api_port = os.environ.get('KUBERNETES_SERVICE_PORT', "443")
|
|
self.api_url = "https://{}:{}".format(api_host, api_port)
|
|
|
|
def _set_kubernetes_service_account_info(self):
|
|
"""Set cert and token info to included on requests to the API"""
|
|
try:
|
|
with open(self.TOKEN_PATH) as token_file:
|
|
token = token_file.read()
|
|
except Exception as e:
|
|
log.error("Unable to read token - {}. Defaulting to using no token".format(e))
|
|
token = None
|
|
self.api_request_header = {'Authorization': 'Bearer {}'.format(token)} if token else None
|
|
self.api_verify = self.CACERT_PATH if os.path.exists(self.CACERT_PATH) else False
|
|
|
|
def get_agent_pod_host(self, return_host_name=False):
|
|
"""Obtain host the agent is running on in Kubernetes.
|
|
Used when trying to connect to services running on the node (Kubelet, cAdvisor)
|
|
"""
|
|
# Get pod name and namespace from environment variables
|
|
pod_name = os.environ.get("AGENT_POD_NAME")
|
|
pod_namespace = os.environ.get("AGENT_POD_NAMESPACE")
|
|
if not pod_name:
|
|
raise exceptions.MissingEnvironmentVariables(
|
|
"pod_name is not set as environment variables cannot derive"
|
|
" host from Kubernetes API")
|
|
if not pod_namespace:
|
|
raise exceptions.MissingEnvironmentVariables(
|
|
"pod_namespace is not set as environment variables cannot "
|
|
"derive host from Kubernetes API")
|
|
pod_url = "/api/v1/namespaces/{}/pods/{}".format(pod_namespace, pod_name)
|
|
try:
|
|
agent_pod = self.get_request(pod_url)
|
|
except Exception as e:
|
|
exception_message = "Could not get agent pod from Kubernetes API" \
|
|
" to get host IP with error - {}".format(e)
|
|
log.exception(exception_message)
|
|
raise exceptions.KubernetesAPIConnectionError(exception_message)
|
|
if not return_host_name:
|
|
return agent_pod['status']['hostIP']
|
|
else:
|
|
return agent_pod['spec']['nodeName']
|
|
|
|
def get_request(self, request_endpoint, as_json=True, retried=False):
|
|
"""Sends request to Kubernetes API with given endpoint.
|
|
Will retry the request once, with updated token/cert, if unauthorized.
|
|
"""
|
|
api_url = self.api_url
|
|
if api_url[-1] == '/':
|
|
api_url = api_url[:-1]
|
|
|
|
if request_endpoint[0] == '/':
|
|
request_endpoint = request_endpoint[1:]
|
|
|
|
request_url = "{}/{}".format(api_url, request_endpoint)
|
|
result = requests.get(request_url,
|
|
timeout=self.connection_timeout,
|
|
headers=self.api_request_header,
|
|
verify=self.api_verify)
|
|
if result.status_code >= 300:
|
|
if result.status_code == 401 and not retried:
|
|
log.info("Could not authenticate with Kubernetes API at the"
|
|
" first time. Rereading in cert and token.")
|
|
self._set_kubernetes_service_account_info()
|
|
return self.get_request(request_endpoint, as_json=as_json,
|
|
retried=True)
|
|
exception_message = "Could not obtain data from {} with the " \
|
|
"given status code {} and return text {}".\
|
|
format(request_url, result.status_code, result.text)
|
|
raise exceptions.KubernetesAPIConnectionError(exception_message)
|
|
return result.json() if as_json else result
|
|
|
|
|
|
class DynamicCheckHelper(object):
|
|
"""Supplements existing check class with reusable functionality to transform
|
|
third-party metrics into Monasca ones in a configurable way
|
|
"""
|
|
|
|
COUNTERS_KEY = 'counters'
|
|
RATES_KEY = 'rates'
|
|
GAUGES_KEY = 'gauges'
|
|
CONFIG_SECTIONS = [GAUGES_KEY, RATES_KEY, COUNTERS_KEY]
|
|
|
|
GAUGE = 0
|
|
RATE = 1
|
|
COUNTER = 2
|
|
SKIP = 3
|
|
METRIC_TYPES = [GAUGE, RATE, COUNTER]
|
|
|
|
DEFAULT_GROUP = ""
|
|
|
|
class MetricSpec(object):
|
|
"""Describes how to filter and map input metrics to Monasca metrics
|
|
"""
|
|
|
|
def __init__(self, metric_type, metric_name):
|
|
"""Construct a metric-specification
|
|
:param metric_type: one of GAUGE, RATE, COUNTER, SKIP
|
|
:param metric_name: normalized name of the metric as reported to Monasca
|
|
"""
|
|
self.metric_type = metric_type
|
|
self.metric_name = metric_name
|
|
|
|
@staticmethod
|
|
def _normalize_dim_value(value):
|
|
"""Normalize an input value
|
|
|
|
* Replace \\x?? values with _
|
|
* Replace illegal characters
|
|
- according to ANTLR grammar:
|
|
( '}' | '{' | '&' | '|' | '>' | '<' | '=' | ',' | ')' | '(' | ' ' | '"' )
|
|
- according to Python API validation: "<>={}(),\"\\\\|;&"
|
|
* Truncate to 255 chars
|
|
:param value: input value
|
|
:return: valid dimension value
|
|
"""
|
|
|
|
return re.sub(r'[|\\;,&=\']', '-',
|
|
re.sub(r'[(}>]', ']',
|
|
re.sub(r'[({<]', '[', value.replace(r'\x2d', '-').
|
|
replace(r'\x7e', '~'))))[:255]
|
|
|
|
class DimMapping(object):
|
|
"""Describes how to transform dictionary like metadata attached to a metric into Monasca dimensions
|
|
"""
|
|
|
|
def __init__(self, dimension, regex='(.*)', separator=None):
|
|
"""C'tor
|
|
:param dimension to be mapped to
|
|
:param regex: regular expression used to extract value from source value
|
|
:param separator: used to concatenate match-groups
|
|
"""
|
|
self.dimension = dimension
|
|
self.regex = regex
|
|
self.separator = separator
|
|
self.cregex = re.compile(regex) if regex != '(.*)' else None
|
|
|
|
def map_value(self, source_value):
|
|
"""Transform source value into target dimension value
|
|
:param source_value: label value to transform
|
|
:return: transformed dimension value or None if the regular
|
|
expression did not match. An empty result (caused by the regex
|
|
having no match-groups) indicates that the label is used for filtering
|
|
but not mapped to a dimension.
|
|
"""
|
|
if self.cregex:
|
|
match_groups = self.cregex.match(source_value)
|
|
if match_groups:
|
|
return DynamicCheckHelper._normalize_dim_value(
|
|
self.separator.join(match_groups.groups()))
|
|
else:
|
|
return None
|
|
else:
|
|
return DynamicCheckHelper._normalize_dim_value(source_value)
|
|
|
|
@staticmethod
|
|
def _build_dimension_map(config):
|
|
"""Builds dimension mappings for the given configuration element
|
|
:param config: 'mappings' element of config
|
|
:return: dictionary mapping source labels to applicable DimMapping objects
|
|
"""
|
|
result = {}
|
|
for dim, spec in config.get('dimensions', {}).items():
|
|
if isinstance(spec, dict):
|
|
label = spec.get('source_key', dim)
|
|
sepa = spec.get('separator', '-')
|
|
regex = spec.get('regex', '(.*)')
|
|
else:
|
|
label = spec
|
|
regex = '(.*)'
|
|
sepa = None
|
|
|
|
# note: source keys can be mapped to multiple dimensions
|
|
arr = result.get(label, [])
|
|
mapping = DynamicCheckHelper.DimMapping(dimension=dim, regex=regex, separator=sepa)
|
|
arr.append(mapping)
|
|
result[label] = arr
|
|
|
|
return result
|
|
|
|
def __init__(self, check, prefix=None, default_mapping=None):
|
|
"""C'tor
|
|
:param check: Target check instance
|
|
"""
|
|
self._check = check
|
|
self._prefix = prefix
|
|
self._groups = {}
|
|
self._metric_map = {}
|
|
self._dimension_map = {}
|
|
self._metric_cache = {}
|
|
self._grp_metric_map = {}
|
|
self._grp_dimension_map = {}
|
|
self._grp_metric_cache = {}
|
|
self._metric_to_group = {}
|
|
for inst in self._check.instances:
|
|
iname = inst['name']
|
|
mappings = inst.get('mapping', default_mapping)
|
|
if mappings:
|
|
# build global name filter and rate/gauge assignment
|
|
self._metric_map[iname] = mappings
|
|
self._metric_cache[iname] = {}
|
|
# build global dimension map
|
|
self._dimension_map[iname] = DynamicCheckHelper._build_dimension_map(mappings)
|
|
# check if groups are used
|
|
groups = mappings.get('groups')
|
|
self._metric_to_group[iname] = {}
|
|
self._groups[iname] = []
|
|
if groups:
|
|
self._groups[iname] = list(groups.keys())
|
|
self._grp_metric_map[iname] = {}
|
|
self._grp_metric_cache[iname] = {}
|
|
self._grp_dimension_map[iname] = {}
|
|
for grp, gspec in groups.items():
|
|
self._grp_metric_map[iname][grp] = gspec
|
|
self._grp_metric_cache[iname][grp] = {}
|
|
self._grp_dimension_map[iname][grp] =\
|
|
DynamicCheckHelper._build_dimension_map(gspec)
|
|
# add the global mappings as pseudo group, so that it is considered when
|
|
# searching for metrics
|
|
self._groups[iname].append(DynamicCheckHelper.DEFAULT_GROUP)
|
|
self._grp_metric_map[iname][DynamicCheckHelper.DEFAULT_GROUP] =\
|
|
self._metric_map[iname]
|
|
self._grp_metric_cache[iname][DynamicCheckHelper.DEFAULT_GROUP] =\
|
|
self._metric_cache[iname]
|
|
self._grp_dimension_map[iname][DynamicCheckHelper.DEFAULT_GROUP] =\
|
|
self._dimension_map[iname]
|
|
|
|
else:
|
|
raise exceptions.CheckException(
|
|
'instance %s is not supported: no element "mapping" found!', iname)
|
|
|
|
def _get_group(self, instance, metric):
|
|
"""Search the group for a metric. Can be used only when metric names unambiguous across groups.
|
|
|
|
:param metric: input metric
|
|
:return: group name or None (if no group matches)
|
|
"""
|
|
iname = instance['name']
|
|
group = self._metric_to_group[iname].get(metric)
|
|
if group is None:
|
|
for g in self._groups[iname]:
|
|
spec = self._fetch_metric_spec(instance, metric, g)
|
|
if spec and spec.metric_type != DynamicCheckHelper.SKIP:
|
|
self._metric_to_group[iname][metric] = g
|
|
return g
|
|
|
|
return group
|
|
|
|
def _fetch_metric_spec(self, instance, metric, group=None):
|
|
"""Checks whether a metric is enabled by the instance configuration
|
|
|
|
:param instance: instance containing the check configuration
|
|
:param metric: metric as reported from metric data source (before mapping)
|
|
:param group: optional metric group, will be used as dot-separated prefix
|
|
"""
|
|
|
|
instance_name = instance['name']
|
|
|
|
# filter and classify the metric
|
|
|
|
if group is not None:
|
|
metric_cache = self._grp_metric_cache[instance_name].get(group, {})
|
|
metric_map = self._grp_metric_map[instance_name].get(group, {})
|
|
return DynamicCheckHelper._lookup_metric(metric, metric_cache, metric_map)
|
|
else:
|
|
metric_cache = self._metric_cache[instance_name]
|
|
metric_map = self._metric_map[instance_name]
|
|
return DynamicCheckHelper._lookup_metric(metric, metric_cache, metric_map)
|
|
|
|
def is_enabled_metric(self, instance, metric, group=None):
|
|
return self._fetch_metric_spec(
|
|
instance, metric, group).metric_type != DynamicCheckHelper.SKIP
|
|
|
|
def push_metric_dict(
|
|
self,
|
|
instance,
|
|
metric_dict,
|
|
labels=None,
|
|
group=None,
|
|
timestamp=None,
|
|
fixed_dimensions=None,
|
|
default_dimensions=None,
|
|
max_depth=0,
|
|
curr_depth=0,
|
|
prefix='',
|
|
index=-1):
|
|
"""This will extract metrics and dimensions from a dictionary.
|
|
|
|
The following mappings are applied:
|
|
|
|
Simple recursive composition of metric names:
|
|
|
|
Input:
|
|
|
|
{
|
|
'server': {
|
|
'requests': 12
|
|
}
|
|
}
|
|
|
|
Configuration:
|
|
|
|
mapping:
|
|
counters:
|
|
- server_requests
|
|
|
|
Output:
|
|
|
|
server_requests=12
|
|
|
|
Mapping of textual values to dimensions to distinguish array elements. Make sure that tests
|
|
attributes are sufficient to distinguish the array elements. If not use the build-in
|
|
'index' dimension.
|
|
|
|
Input:
|
|
|
|
{
|
|
'server': [
|
|
{
|
|
'role': 'master,
|
|
'node_name': 'server0',
|
|
'requests': 1500
|
|
},
|
|
{
|
|
'role': 'slave',
|
|
'node_name': 'server1',
|
|
'requests': 1000
|
|
},
|
|
{
|
|
'role': 'slave',
|
|
'node_name': 'server2',
|
|
'requests': 500
|
|
}
|
|
}
|
|
}
|
|
|
|
Configuration:
|
|
|
|
mapping:
|
|
dimensions:
|
|
server_role: role
|
|
node_name: node_name
|
|
rates:
|
|
- requests
|
|
|
|
Output:
|
|
|
|
server_requests{server_role=master, node_name=server0} = 1500.0
|
|
server_requests{server_role=slave, node_name=server1} = 1000.0
|
|
server_requests{server_role=slave, node_name=server2} = 500.0
|
|
|
|
|
|
Distinguish array elements where no textual attribute are available or no mapping has been
|
|
configured for them.
|
|
In that case an 'index' dimension will be attached to the metric which has to be mapped
|
|
properly.
|
|
|
|
Input:
|
|
|
|
{
|
|
'server': [
|
|
{
|
|
'requests': 1500
|
|
},
|
|
{
|
|
'requests': 1000
|
|
},
|
|
{
|
|
'requests': 500
|
|
}
|
|
}
|
|
}
|
|
|
|
Configuration:
|
|
|
|
mapping:
|
|
dimensions:
|
|
server_no: index # index is a predefined label
|
|
counters:
|
|
- server_requests
|
|
|
|
Result:
|
|
|
|
server_requests{server_no=0} = 1500.0
|
|
server_requests{server_no=1} = 1000.0
|
|
server_requests{server_no=2} = 500.0
|
|
|
|
|
|
:param instance: Instance to submit to
|
|
:param metric_dict: input data as dictionary
|
|
:param labels: labels to be mapped to dimensions
|
|
:param group: group to use for mapping labels and prefixing
|
|
:param timestamp: timestamp to report for the measurement
|
|
:param fixed_dimensions: dimensions which are always added with fixed values
|
|
:param default_dimensions: dimensions to be added, can be overwritten by actual data in
|
|
metric_dict
|
|
:param max_depth: max. depth to recurse
|
|
:param curr_depth: depth of recursion
|
|
:param prefix: prefix to prepend to any metric
|
|
:param index: current index when traversing through a list
|
|
"""
|
|
|
|
# when traversing through an array, each element must be distinguished with dimensions
|
|
# therefore additional dimensions need to be calculated from the siblings
|
|
# of the actual number valued fields
|
|
if default_dimensions is None:
|
|
default_dimensions = {}
|
|
if fixed_dimensions is None:
|
|
fixed_dimensions = {}
|
|
if labels is None:
|
|
labels = {}
|
|
if index != -1:
|
|
ext_labels = self.extract_dist_labels(
|
|
instance['name'], group, metric_dict, labels.copy(), index)
|
|
if not ext_labels:
|
|
log.debug(
|
|
"skipping array due to lack of mapped dimensions for group %s "
|
|
"(at least 'index' should be supported)",
|
|
group if group else '<root>')
|
|
return
|
|
|
|
else:
|
|
ext_labels = labels.copy()
|
|
|
|
for element, child in metric_dict.items():
|
|
# if child is a dictionary, then recurse
|
|
if isinstance(child, dict) and curr_depth < max_depth:
|
|
self.push_metric_dict(
|
|
instance,
|
|
child,
|
|
ext_labels,
|
|
group,
|
|
timestamp,
|
|
fixed_dimensions,
|
|
default_dimensions,
|
|
max_depth,
|
|
curr_depth + 1,
|
|
prefix + element + '_')
|
|
# if child is a number, assume that it is a metric (it will be filtered
|
|
# out by the rate/gauge names)
|
|
elif isinstance(child, Number):
|
|
self.push_metric(
|
|
instance,
|
|
prefix + element,
|
|
float(child),
|
|
ext_labels,
|
|
group,
|
|
timestamp,
|
|
fixed_dimensions,
|
|
default_dimensions)
|
|
# if it is a list, then each array needs to be added. Additional dimensions must be
|
|
# found in order to distinguish the measurements.
|
|
elif isinstance(child, list):
|
|
for i, child_element in enumerate(child):
|
|
if isinstance(child_element, dict):
|
|
if curr_depth < max_depth:
|
|
self.push_metric_dict(
|
|
instance,
|
|
child_element,
|
|
ext_labels,
|
|
group,
|
|
timestamp,
|
|
fixed_dimensions,
|
|
default_dimensions,
|
|
max_depth,
|
|
curr_depth + 1,
|
|
prefix + element + '_',
|
|
index=i)
|
|
elif isinstance(child_element, Number):
|
|
if len(self._get_mappings(instance['name'], group, 'index')) > 0:
|
|
idx_labels = ext_labels.copy()
|
|
idx_labels['index'] = str(i)
|
|
self.push_metric(
|
|
instance,
|
|
prefix + element,
|
|
float(child_element),
|
|
idx_labels,
|
|
group,
|
|
timestamp,
|
|
fixed_dimensions,
|
|
default_dimensions)
|
|
else:
|
|
log.debug(
|
|
"skipping array due to lack of mapped 'index' dimensions"
|
|
"for group %s",
|
|
group if group else '<root>')
|
|
else:
|
|
log.debug(
|
|
'nested arrays are not supported for configurable extraction of'
|
|
'element %s', element)
|
|
|
|
def extract_dist_labels(self, instance_name, group, metric_dict, labels, index):
|
|
"""Extract additional distinguishing labels from metric dictionary. All top-level
|
|
attributes which are strings and for which a dimension mapping is available will be
|
|
transformed into dimensions.
|
|
:param instance_name: instance to be used
|
|
:param group: metric group or None for root/unspecified group
|
|
:param metric_dict: input dictionary containing the metric at the top-level
|
|
:param labels: labels dictionary to extend with the additional found metrics
|
|
:param index: index value to be used as fallback if no labels can be derived from
|
|
string-valued attributes or the derived labels are not mapped in the config.
|
|
:return: Extended labels, already including the 'labels' passed into this method
|
|
"""
|
|
ext_labels = None
|
|
# collect additional dimensions first from non-metrics
|
|
for element, child in metric_dict.items():
|
|
if isinstance(
|
|
child,
|
|
str) and len(
|
|
self._get_mappings(
|
|
instance_name,
|
|
group,
|
|
element)) > 0:
|
|
if not ext_labels:
|
|
ext_labels = labels.copy()
|
|
ext_labels[element] = child
|
|
# if no additional labels supplied just take the index (if it is mapped)
|
|
if not ext_labels and len(self._get_mappings(instance_name, group, 'index')) > 0:
|
|
if not ext_labels:
|
|
ext_labels = labels.copy()
|
|
ext_labels['index'] = str(index)
|
|
|
|
return ext_labels
|
|
|
|
def push_metric(
|
|
self,
|
|
instance,
|
|
metric,
|
|
value,
|
|
labels=None,
|
|
group=None,
|
|
timestamp=None,
|
|
fixed_dimensions=None,
|
|
default_dimensions=None):
|
|
"""Pushes a meter using the configured mapping information to determine metric_type
|
|
and map the name and dimensions
|
|
|
|
:param instance: instance containing the check configuration
|
|
:param value: metric value (float)
|
|
:param metric: metric as reported from metric data source (before mapping)
|
|
:param labels: labels/tags as reported from the metric data source (before mapping)
|
|
:param timestamp: optional timestamp to handle rates properly
|
|
:param group: specify the metric group, otherwise it will be determined from the metric name
|
|
:param fixed_dimensions:
|
|
:param default_dimensions:
|
|
"""
|
|
|
|
# determine group automatically if not specified
|
|
if fixed_dimensions is None:
|
|
fixed_dimensions = {}
|
|
if labels is None:
|
|
labels = {}
|
|
if default_dimensions is None:
|
|
default_dimensions = {}
|
|
if group is None:
|
|
group = self._get_group(instance, metric)
|
|
|
|
metric_entry = self._fetch_metric_spec(instance, metric, group)
|
|
if metric_entry.metric_type == DynamicCheckHelper.SKIP:
|
|
return False
|
|
|
|
if self._prefix:
|
|
metric_prefix = self._prefix + '.'
|
|
else:
|
|
metric_prefix = ''
|
|
|
|
if group:
|
|
metric_prefix += group + '.'
|
|
|
|
# determine the metric name
|
|
metric_name = metric_prefix + metric_entry.metric_name
|
|
# determine the target dimensions
|
|
dims = self._map_dimensions(instance['name'], labels, group, default_dimensions)
|
|
if dims is None:
|
|
# regex for at least one dimension filtered the metric out
|
|
return True
|
|
|
|
# apply fixed default dimensions
|
|
if fixed_dimensions:
|
|
dims.update(fixed_dimensions)
|
|
|
|
log.debug(
|
|
'push %s %s = %s {%s}',
|
|
metric_entry.metric_type,
|
|
metric_entry.metric_name,
|
|
value,
|
|
dims)
|
|
|
|
if metric_entry.metric_type == DynamicCheckHelper.RATE:
|
|
self._check.rate(metric_name, float(value), dimensions=dims)
|
|
elif metric_entry.metric_type == DynamicCheckHelper.GAUGE:
|
|
self._check.gauge(metric_name, float(value), timestamp=timestamp, dimensions=dims)
|
|
elif metric_entry.metric_type == DynamicCheckHelper.COUNTER:
|
|
self._check.increment(metric_name, float(value), dimensions=dims)
|
|
|
|
return True
|
|
|
|
def get_mapped_metrics(self, instance):
|
|
"""Returns input metric names or regex for which a mapping has been defined
|
|
:param instance: instance to consider
|
|
:return: array of metrics
|
|
"""
|
|
metric_list = []
|
|
iname = instance['name']
|
|
# collect level-0 metrics
|
|
metric_map = self._metric_map[iname]
|
|
metric_list.extend(metric_map.get(DynamicCheckHelper.GAUGES_KEY, []))
|
|
metric_list.extend(metric_map.get(DynamicCheckHelper.RATES_KEY, []))
|
|
metric_list.extend(metric_map.get(DynamicCheckHelper.COUNTERS_KEY, []))
|
|
# collect group specific metrics
|
|
grp_metric_map = self._grp_metric_map.get(iname, {})
|
|
for gname, gmmap in grp_metric_map.items():
|
|
metric_list.extend(gmmap.get(DynamicCheckHelper.GAUGES_KEY, []))
|
|
metric_list.extend(gmmap.get(DynamicCheckHelper.RATES_KEY, []))
|
|
metric_list.extend(gmmap.get(DynamicCheckHelper.COUNTERS_KEY, []))
|
|
|
|
return metric_list
|
|
|
|
def _map_dimensions(self, instance_name, labels, group, default_dimensions):
|
|
"""Transforms labels attached to input metrics into Monasca dimensions
|
|
:param default_dimensions:
|
|
:param group:
|
|
:param instance_name:
|
|
:param labels:
|
|
:return: mapped dimensions or None if the dimensions filter did not match
|
|
and the metric needs to be filtered
|
|
"""
|
|
dims = default_dimensions.copy()
|
|
# map all specified dimension all keys
|
|
for labelname, labelvalue in labels.items():
|
|
mapping_arr = self._get_mappings(instance_name, group, labelname)
|
|
|
|
target_dim = None
|
|
for map_spec in mapping_arr:
|
|
try:
|
|
# map the dimension name
|
|
target_dim = map_spec.dimension
|
|
# apply the mapping function to the value
|
|
if target_dim not in dims: # do not overwrite
|
|
mapped_value = map_spec.map_value(labelvalue)
|
|
if mapped_value is None:
|
|
# None means: filter it out based on dimension value
|
|
return None
|
|
elif mapped_value != '':
|
|
dims[target_dim] = mapped_value
|
|
# else the dimension will not map
|
|
except (IndexError, AttributeError): # probably the regex was faulty
|
|
log.exception(
|
|
'dimension %s value could not be mapped from %s: regex for mapped'
|
|
'dimension %s does not match %s',
|
|
target_dim, labelvalue, labelname, map_spec.regex)
|
|
return None
|
|
|
|
return dims
|
|
|
|
def _get_mappings(self, instance_name, group, labelname):
|
|
# obtain mappings
|
|
# check group-specific ones first
|
|
if group:
|
|
mapping_arr = self._grp_dimension_map[instance_name].get(group, {}).get(labelname, [])
|
|
else:
|
|
mapping_arr = []
|
|
# fall-back to global ones
|
|
mapping_arr.extend(self._dimension_map[instance_name].get(labelname, []))
|
|
return mapping_arr
|
|
|
|
@staticmethod
|
|
def _create_metric_spec(metric, metric_type, metric_map):
|
|
"""Get or create MetricSpec if metric is in list for metric_type
|
|
|
|
:param metric: incoming metric name
|
|
:param metric_type: GAUGE, RATE, COUNTER
|
|
:param metric_map: dictionary with mapping configuration for a metric group or
|
|
the entire instance
|
|
:return: new MetricSpec entry or None if metric is not listed as metric_type
|
|
"""
|
|
re_list = metric_map.get(DynamicCheckHelper.CONFIG_SECTIONS[metric_type], [])
|
|
for rx in re_list:
|
|
match_groups = re.match(rx, metric)
|
|
if match_groups:
|
|
metric_entry = DynamicCheckHelper.MetricSpec(
|
|
metric_type=metric_type,
|
|
metric_name=DynamicCheckHelper._normalize_metricname(
|
|
metric,
|
|
match_groups))
|
|
return metric_entry
|
|
|
|
return None
|
|
|
|
@staticmethod
|
|
def _lookup_metric(metric, metric_cache, metric_map):
|
|
"""Search cache for a MetricSpec and create if missing
|
|
:param metric: input metric name
|
|
:param metric_cache: cache to use
|
|
:param metric_map: mapping config element to consider
|
|
:return: MetricSpec for the output metric
|
|
"""
|
|
i = DynamicCheckHelper.GAUGE
|
|
metric_entry = metric_cache.get(metric, None)
|
|
while not metric_entry and i < len(DynamicCheckHelper.METRIC_TYPES):
|
|
metric_entry = DynamicCheckHelper._create_metric_spec(
|
|
metric, DynamicCheckHelper.METRIC_TYPES[i], metric_map)
|
|
i += 1
|
|
|
|
if not metric_entry:
|
|
# fall-through
|
|
metric_entry = DynamicCheckHelper.MetricSpec(
|
|
metric_type=DynamicCheckHelper.SKIP,
|
|
metric_name=DynamicCheckHelper._normalize_metricname(metric))
|
|
|
|
metric_cache[metric] = metric_entry
|
|
|
|
return metric_entry
|
|
|
|
@staticmethod
|
|
def _normalize_metricname(metric, match_groups=None):
|
|
# map metric name first
|
|
if match_groups and match_groups.lastindex \
|
|
and match_groups.lastindex > 0:
|
|
metric = '_'.join(match_groups.groups())
|
|
|
|
metric = re.sub(
|
|
'(?!^)([A-Z]+)',
|
|
r'_\1',
|
|
metric.replace(
|
|
'.',
|
|
'_')).replace(
|
|
'__',
|
|
'_').lower()
|
|
metric = re.sub(r"[,+*\-/()\[\]{}]", "_", metric)
|
|
# Eliminate multiple _
|
|
metric = re.sub(r"__+", "_", metric)
|
|
# Don't start/end with _
|
|
metric = re.sub(r"^_", "", metric)
|
|
metric = re.sub(r"_$", "", metric)
|
|
# Drop ._ and _.
|
|
metric = re.sub(r"\._", ".", metric)
|
|
metric = re.sub(r"_\.", ".", metric)
|
|
|
|
return metric
|
|
|
|
|
|
def get_pod_dimensions(kubernetes_connector, pod_metadata, kubernetes_labels):
|
|
pod_name = pod_metadata['name']
|
|
pod_dimensions = {'pod_name': pod_name, 'namespace': pod_metadata['namespace']}
|
|
if "labels" in pod_metadata:
|
|
pod_labels = pod_metadata['labels']
|
|
for label in kubernetes_labels:
|
|
if label in pod_labels:
|
|
pod_dimensions[label] = pod_labels[label]
|
|
pod_owner_dimension_set = get_pod_owner(kubernetes_connector, pod_metadata)
|
|
if pod_owner_dimension_set:
|
|
pod_dimensions[pod_owner_dimension_set[0]] = pod_owner_dimension_set[1]
|
|
pod_dimensions["owner_type"] = pod_owner_dimension_set[0]
|
|
return pod_dimensions
|
|
|
|
|
|
def _attempt_to_get_owner_name(kubernetes_connector, resource_endpoint):
|
|
try:
|
|
resource = kubernetes_connector.get_request(resource_endpoint)
|
|
resource_metadata = resource['metadata']
|
|
pod_owner_pair = _parse_manifest_for_owner(resource_metadata)
|
|
return None if not pod_owner_pair else pod_owner_pair[1]
|
|
except Exception as e:
|
|
log.info("Could not connect to api to get owner data - {}".format(e))
|
|
return None
|
|
return None
|
|
|
|
|
|
def _parse_manifest_for_owner(resource_metadata):
|
|
resource_name = resource_metadata['name']
|
|
owner_references = resource_metadata.get('ownerReferences', None)
|
|
if owner_references:
|
|
try:
|
|
if len(owner_references) > 1:
|
|
log.warn("More then one owner for resource {}".format(resource_name))
|
|
owner_reference = owner_references[0]
|
|
resource_owner_type = owner_reference['kind']
|
|
resource_owner_name = owner_reference['name']
|
|
return resource_owner_type, resource_owner_name
|
|
except Exception:
|
|
log.info("Could not get owner from ownerReferences"
|
|
"for resource {}".format(resource_name))
|
|
# Try to get owner from annotations
|
|
else:
|
|
try:
|
|
resource_created_by = json.loads(
|
|
resource_metadata['annotations']['kubernetes.io/created-by'])
|
|
resource_owner_type = resource_created_by['reference']['kind']
|
|
resource_owner_name = resource_created_by['reference']['name']
|
|
return resource_owner_type, resource_owner_name
|
|
except Exception:
|
|
log.info("Could not get resource owner from annotations"
|
|
"for resource {}".format(resource_name))
|
|
return None
|
|
|
|
|
|
def _get_pod_owner_pair(kubernetes_connector, pod_owner_type, pod_owner_name, pod_namespace):
|
|
if pod_owner_type == "ReplicationController":
|
|
return 'replication_controller', pod_owner_name
|
|
elif pod_owner_type == "ReplicaSet":
|
|
if not kubernetes_connector:
|
|
log.info("Can not set deployment name as connection information to API is not set. "
|
|
"Setting ReplicaSet as dimension")
|
|
deployment_name = None
|
|
else:
|
|
replicaset_endpoint = "/apis/extensions/v1beta1/namespaces/{}/replicasets/{}".format(
|
|
pod_namespace, pod_owner_name)
|
|
deployment_name = _attempt_to_get_owner_name(kubernetes_connector, replicaset_endpoint)
|
|
if not deployment_name:
|
|
return 'replica_set', pod_owner_name
|
|
else:
|
|
return 'deployment', deployment_name
|
|
elif pod_owner_type == "DaemonSet":
|
|
return 'daemon_set', pod_owner_name
|
|
elif pod_owner_type == "StatefulSet":
|
|
return 'stateful_set', pod_owner_name
|
|
elif pod_owner_type == "Job":
|
|
if not kubernetes_connector:
|
|
log.info("Can not set cronjob name as connection information to API is not set. "
|
|
"Setting job as dimension")
|
|
cronjob_name = None
|
|
else:
|
|
job_endpoint = "/apis/batch/v1/namespaces/{}/jobs/{}".format(
|
|
pod_namespace, pod_owner_name)
|
|
cronjob_name = _attempt_to_get_owner_name(kubernetes_connector, job_endpoint)
|
|
if not cronjob_name:
|
|
return 'job', pod_owner_name
|
|
else:
|
|
return 'cronjob', cronjob_name
|
|
else:
|
|
log.warn("Unsupported pod owner kind {}".format(pod_owner_type))
|
|
return None
|
|
|
|
|
|
def get_pod_owner(kubernetes_connector, pod_metadata):
|
|
pod_namespace = pod_metadata['namespace']
|
|
owner_pair = _parse_manifest_for_owner(pod_metadata)
|
|
if owner_pair:
|
|
return _get_pod_owner_pair(
|
|
kubernetes_connector,
|
|
owner_pair[0],
|
|
owner_pair[1],
|
|
pod_namespace)
|
|
return None
|