From 406ee0cd152643addc0f48815ea78538216e440e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kukr=C3=A1l?= Date: Thu, 14 Dec 2017 15:07:52 +0100 Subject: [PATCH 1/7] add metrics for users and organizations --- kqueen/blueprints/metrics/helpers.py | 84 +++++++++++++++++++++++ kqueen/blueprints/metrics/test_helpers.py | 10 +++ kqueen/middleware.py | 2 +- 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 kqueen/blueprints/metrics/helpers.py create mode 100644 kqueen/blueprints/metrics/test_helpers.py diff --git a/kqueen/blueprints/metrics/helpers.py b/kqueen/blueprints/metrics/helpers.py new file mode 100644 index 00000000..285878b9 --- /dev/null +++ b/kqueen/blueprints/metrics/helpers.py @@ -0,0 +1,84 @@ +from collections import defaultdict +from kqueen.models import Organization +from kqueen.models import User +from prometheus_client import Gauge + +import logging + +logger = logging.getLogger(__name__) + +metrics = { + 'users_by_namespace': Gauge('users_by_namespace', 'Number of users in namespace', ['namespace']), + 'users_by_role': Gauge('users_by_role', 'Number of users by role', ['role']), + 'users_active': Gauge('users_active', 'Number of users by role'), + 'organization_count': Gauge('organization_count', 'Number of organizations'), +} + + +class MetricUpdater: + def __init__(self): + self.data = {} + + self.get_data() + + def update_metrics(self): + for metric_name, metric in metrics.items(): + + update_function_name = 'update_metric_{}'.format(metric_name) + + logger.debug('Updating metric {metric_name}, with function {function}'.format( + metric_name=metric_name, + function=update_function_name, + )) + + try: + fnc = getattr(self, update_function_name) + except AttributeError: + msg = 'Missing update function {function} for metric {metric_name}'.format( + metric_name=metric_name, + function=update_function_name + ) + + raise Exception(msg) + + # run update function + # TODO: use asyncio for concurrent updates + fnc(metric) + + def get_data(self): + # users + cls = User + namespace = None + + sum = defaultdict(lambda: defaultdict(lambda: 0)) + + for obj_id, obj in cls.list(namespace, True).items(): + user_dict = obj.get_dict(True) + + user_namespace = user_dict['organization']['namespace'] + user_role = user_dict['role'] + user_active = user_dict['active'] + + sum['namespace'][user_namespace] += 1 + sum['roles'][user_role] += 1 + sum['active'][user_active] += 1 + + self.data['users'] = sum + + # organizations + objs = Organization.list(None, False) + self.data['organizations'] = len(objs) + + def update_metric_users_by_namespace(self, metric): + for namespace, count in self.data['users']['namespace'].items(): + metric.labels(namespace).set(count) + + def update_metric_users_by_role(self, metric): + for role, count in self.data['users']['roles'].items(): + metric.labels(role).set(count) + + def update_metric_users_active(self, metric): + metric.set(self.data['users']['active'][True]) + + def update_metric_organization_count(self, metric): + metric.set(self.data['organizations']) diff --git a/kqueen/blueprints/metrics/test_helpers.py b/kqueen/blueprints/metrics/test_helpers.py new file mode 100644 index 00000000..660ecd1d --- /dev/null +++ b/kqueen/blueprints/metrics/test_helpers.py @@ -0,0 +1,10 @@ +from .helpers import MetricUpdater +from prometheus_client import generate_latest + + +def test_dummy(user): + m = MetricUpdater() + m.update_metrics() + + latest = generate_latest().decode('utf-8') + print(latest) diff --git a/kqueen/middleware.py b/kqueen/middleware.py index 1895ba01..3619bffb 100644 --- a/kqueen/middleware.py +++ b/kqueen/middleware.py @@ -8,7 +8,7 @@ # Prometheus metrics REQUEST_COUNT = Counter( 'request_count', - 'HTPP Request Count', + 'HTTP Request Count', ['method', 'endpoint', 'http_status'] ) REQUEST_LATENCY = Histogram( From 65c90dbe514ffb8e451e482f2472ad2f6b9c238c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kukr=C3=A1l?= Date: Mon, 11 Dec 2017 12:37:21 +0100 Subject: [PATCH 2/7] add encrypted field We can specify `encrypted=True` for any field and I will make this to be saved encrypted using AES256. --- Dockerfile | 2 +- kqueen/storages/etcd.py | 81 ++++++++++++++++--- kqueen/storages/test_model_fields.py | 117 ++++++++++++++++++++------- requirements.txt | 5 +- setup.py | 1 + 5 files changed, 166 insertions(+), 40 deletions(-) diff --git a/Dockerfile b/Dockerfile index db94a12e..a702b359 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.6-slim +FROM python:3.6 # prepare directory WORKDIR /code diff --git a/kqueen/storages/etcd.py b/kqueen/storages/etcd.py index 52375f43..92b9b6d0 100644 --- a/kqueen/storages/etcd.py +++ b/kqueen/storages/etcd.py @@ -1,15 +1,19 @@ +from .exceptions import BackendError +from Crypto import Random +from Crypto.Cipher import AES +from datetime import datetime +from dateutil.parser import parse as du_parse +from flask import current_app +from kqueen.config import current_config + +import base64 import bcrypt import etcd +import hashlib +import importlib import json import logging import uuid -import importlib -import six -from datetime import datetime -from dateutil.parser import parse as du_parse -from kqueen.config import current_config -from flask import current_app -from .exceptions import BackendError logger = logging.getLogger(__name__) @@ -46,7 +50,12 @@ def __init__(self, *args, **kwargs): else: self.value = kwargs.get('value', None) + # set block size for crypto + self.bs = 16 + + # field parameters self.required = kwargs.get('required', False) + self.encrypted = kwargs.get('encrypted', False) def on_create(self, **kwargs): """Optional action that should be run only on newly created objects""" @@ -97,6 +106,55 @@ def validate(self): """ return True + def _get_encryption_key(self): + """ + Read encryption key and format it. + + Returns: + Encryption key. + """ + + # check for key + config = current_config() + key = config.get('SECRET_KEY') + + if key is None: + raise Exception('Missing SECRET_KEY') + + # calculate hash passowrd + return hashlib.sha256(key.encode('utf-8')).digest()[:self.bs] + + def _pad(self, s): + return s + (self.bs - len(s) % self.bs) * chr(self.bs - len(s) % self.bs) + + def _unpad(self, s): + return s[:-ord(s[len(s) - 1:])] + + def encrypt(self): + """Encrypt stored value""" + + key = self._get_encryption_key() + padded = self._pad(str(self.serialize())) + + iv = Random.new().read(self.bs) + suite = AES.new(key, AES.MODE_CBC, iv) + encrypted = suite.encrypt(padded) + encoded = base64.b64encode(iv + encrypted) + + return encoded + + def decrypt(self, crypted, **kwargs): + key = self._get_encryption_key() + decoded = base64.b64decode(crypted) + + iv = decoded[:self.bs] + suite = AES.new(key, AES.MODE_CBC, iv) + decrypted = suite.decrypt(decoded[self.bs:]).decode('utf-8') + + serialized = self._unpad(decrypted) + self.deserialize(serialized, **kwargs) + print('Seralizing from: {}, value: {}'.format(serialized, self.value)) + def __str__(self): return str(self.value) @@ -115,7 +173,7 @@ class StringField(Field): class BoolField(Field): def deserialize(self, serialized, **kwargs): - if isinstance(serialized, six.string_types): + if isinstance(serialized, str): value = json.loads(serialized) self.set_value(value, **kwargs) @@ -156,10 +214,15 @@ class DatetimeField(Field): def deserialize(self, serialized, **kwargs): value = None + + # convert to float if serialized is digit + if isinstance(serialized, str) and serialized.isdigit(): + serialized = float(serialized) + if isinstance(serialized, (float, int)): value = datetime.fromtimestamp(serialized) self.set_value(value, **kwargs) - elif isinstance(serialized, six.string_types): + elif isinstance(serialized, str): value = du_parse(serialized) self.set_value(value, **kwargs) diff --git a/kqueen/storages/test_model_fields.py b/kqueen/storages/test_model_fields.py index c0dc4885..9ef294fd 100644 --- a/kqueen/storages/test_model_fields.py +++ b/kqueen/storages/test_model_fields.py @@ -12,20 +12,30 @@ import datetime import pytest +import itertools -def create_model(required=False, global_ns=False): +def create_model(required=False, global_ns=False, encrypted=False): class TestModel(Model, metaclass=ModelMeta): if global_ns: global_namespace = global_ns - id = IdField(required=required) - string = StringField(required=required) - json = JSONField(required=required) - password = PasswordField(required=required) - relation = RelationField(required=required) - datetime = DatetimeField(required=required) - boolean = BoolField(required=required) + id = IdField(required=required, encrypte=encrypted) + string = StringField(required=required, encrypte=encrypted) + json = JSONField(required=required, encrypte=encrypted) + password = PasswordField(required=required, encrypted=encrypted) + relation = RelationField(required=required, encrypte=encrypted) + datetime = DatetimeField(required=required, encrypte=encrypted) + boolean = BoolField(required=required, encrypte=encrypted) + + _required = required + _global_ns = global_ns + _encrypted = encrypted + + if _global_ns: + _namespace = None + else: + _namespace = namespace return TestModel @@ -58,8 +68,25 @@ def model_serialized(related=None): ) -@pytest.fixture -def create_object(): +@pytest.fixture(params=itertools.product(*[ + [True, False], + [True, False], + [True, False] +])) +def get_object(request): + return create_object(*request.param) + + +@pytest.fixture(params=itertools.product(*[ + [True, False], + [True, False], + [True, False] +])) +def get_model(request): + return create_model(*request.param) + + +def create_object(required=False, global_ns=False, encrypted=False): model = create_model() obj1 = model(namespace, **model_kwargs) @@ -111,14 +138,14 @@ def test_save_skip_validation(self): class TestModelAddId: - def test_id_added(self, create_object): - obj = create_object + def test_id_added(self, get_object): + obj = get_object assert obj.id is None assert obj.verify_id() assert obj.id is not None - create_object.save() + obj.save() class TestRequiredFields: @@ -131,14 +158,14 @@ def test_required(self, required): class TestGetFieldNames: - def test_get_field_names(self, create_object): - field_names = create_object.__class__.get_field_names() + def test_get_field_names(self, get_object): + field_names = get_object.__class__.get_field_names() req = model_fields assert set(field_names) == set(req) - def test_get_dict(self, create_object): - dicted = create_object.get_dict() + def test_get_dict(self, get_object): + dicted = get_object.get_dict() assert isinstance(dicted, dict) @@ -146,8 +173,8 @@ def test_get_dict(self, create_object): class TestFieldSetGet: """Validate getters and setters for fields""" @pytest.mark.parametrize('field_name', model_kwargs.keys()) - def test_get_fields(self, field_name, create_object): - at = getattr(create_object, field_name) + def test_get_fields(self, field_name, get_object): + at = getattr(get_object, field_name) req = model_kwargs[field_name] assert at == req @@ -166,22 +193,22 @@ def test_set_fields(self, field_name): class TestSerialization: """Serialization and deserialization create same objects""" - def test_serizalization(self, create_object): - serialized = create_object.serialize() + def test_serizalization(self, get_object): + serialized = get_object.serialize() - assert serialized == model_serialized(related=create_object.relation) + assert serialized == model_serialized(related=get_object.relation) - def test_deserialization(self, create_object, monkeypatch): + def test_deserialization(self, get_object, monkeypatch): def fake(self, class_name): - return create_object.__class__ + return get_object.__class__ monkeypatch.setattr(RelationField, '_get_related_class', fake) - object_class = create_object.__class__ - create_object.save() - new_object = object_class.deserialize(create_object.serialize(), namespace=namespace) + object_class = get_object.__class__ + get_object.save() + new_object = object_class.deserialize(get_object.serialize(), namespace=namespace) - assert new_object.get_dict() == create_object.get_dict() + assert new_object.get_dict() == get_object.get_dict() class TestGetDict: @@ -401,3 +428,37 @@ def test_dict_value_returns_boolean(self): self.field.set_value(self.boolean) assert self.field.dict_value() == self.boolean + + +# +# Encryption +# +class TestFieldEncryption: + def test_get_encryption_key(self, get_model): + + obj = get_model(get_model._namespace, **model_kwargs) + + field = obj._string + KEY_LENGTH = obj._string.bs + key = field._get_encryption_key() + + assert len(key) == KEY_LENGTH + + @pytest.mark.parametrize('field_name, field_value', model_kwargs.items()) + def test_encryption_and_decryption(self, field_name, field_value): + + cls = create_model(False, False, True) + obj = cls(namespace, **model_kwargs) + + field = getattr(obj, '_{}'.format(field_name)) + field.set_value(field_value) + + # encryption + encrypted = field.encrypt() + print(encrypted) + + # decryption + field.set_value(None) + field.decrypt(encrypted) + + assert field.value == field_value diff --git a/requirements.txt b/requirements.txt index 1eb6f121..792481d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,13 +2,14 @@ bcrypt Flask==0.12.2 Flask-JWT==0.3.2 flask-swagger-ui +gunicorn kubernetes +prometheus_client +pycrypto python-etcd python-jenkins -prometheus_client pyyaml requests -gunicorn # dev coveralls diff --git a/setup.py b/setup.py index fed29b24..cb8ee896 100644 --- a/setup.py +++ b/setup.py @@ -37,6 +37,7 @@ 'flask-swagger-ui', 'gunicorn', 'kubernetes', + 'pycrypto', 'prometheus_client', 'python-etcd', 'python-jenkins', From c91566be79fc97057585faf17cca2468cdff2f15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kukr=C3=A1l?= Date: Thu, 14 Dec 2017 09:50:11 +0100 Subject: [PATCH 3/7] use encrypte and decrypt method for saving data to storage backend It will just use serialization in case of field isn't encrypted. --- kqueen/storages/etcd.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/kqueen/storages/etcd.py b/kqueen/storages/etcd.py index 92b9b6d0..c128b9cc 100644 --- a/kqueen/storages/etcd.py +++ b/kqueen/storages/etcd.py @@ -133,6 +133,9 @@ def _unpad(self, s): def encrypt(self): """Encrypt stored value""" + if not self.encrypted: + return self.serialize() + key = self._get_encryption_key() padded = self._pad(str(self.serialize())) @@ -144,6 +147,10 @@ def encrypt(self): return encoded def decrypt(self, crypted, **kwargs): + + if not self.encrypted: + return self.deserialize(crypted, **kwargs) + key = self._get_encryption_key() decoded = base64.b64decode(crypted) @@ -153,7 +160,6 @@ def decrypt(self, crypted, **kwargs): serialized = self._unpad(decrypted) self.deserialize(serialized, **kwargs) - print('Seralizing from: {}, value: {}'.format(serialized, self.value)) def __str__(self): return str(self.value) @@ -492,7 +498,7 @@ def deserialize(cls, serialized, **kwargs): field_class = field.__class__ if hasattr(field_class, 'is_field') and toplevel.get(field_name) is not None: field_object = field_class(**field.__dict__) - field_object.deserialize(toplevel[field_name], **kwargs) + field_object.decrypt(toplevel[field_name], **kwargs) object_kwargs[field_name] = field_object.get_value() @@ -634,7 +640,7 @@ def get_dict(self, expand=False): def serialize(self): serdict = {} for attr_name, attr in self.get_dict().items(): - serdict[attr_name] = getattr(self, '_{}'.format(attr_name)).serialize() + serdict[attr_name] = getattr(self, '_{}'.format(attr_name)).encrypt() return json.dumps(serdict) From b08996aa855ac4bba69ee493a5964cffaa104721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kukr=C3=A1l?= Date: Thu, 14 Dec 2017 10:35:36 +0100 Subject: [PATCH 4/7] add tests for storing encrypted fields --- kqueen/storages/etcd.py | 2 +- kqueen/storages/test_model_fields.py | 39 ++++++++++++++++------------ 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/kqueen/storages/etcd.py b/kqueen/storages/etcd.py index c128b9cc..d961f48d 100644 --- a/kqueen/storages/etcd.py +++ b/kqueen/storages/etcd.py @@ -142,7 +142,7 @@ def encrypt(self): iv = Random.new().read(self.bs) suite = AES.new(key, AES.MODE_CBC, iv) encrypted = suite.encrypt(padded) - encoded = base64.b64encode(iv + encrypted) + encoded = base64.b64encode(iv + encrypted).decode('utf-8') return encoded diff --git a/kqueen/storages/test_model_fields.py b/kqueen/storages/test_model_fields.py index 9ef294fd..a6a6b2e5 100644 --- a/kqueen/storages/test_model_fields.py +++ b/kqueen/storages/test_model_fields.py @@ -20,13 +20,13 @@ class TestModel(Model, metaclass=ModelMeta): if global_ns: global_namespace = global_ns - id = IdField(required=required, encrypte=encrypted) - string = StringField(required=required, encrypte=encrypted) - json = JSONField(required=required, encrypte=encrypted) + id = IdField(required=required, encrypted=encrypted) + string = StringField(required=required, encrypted=encrypted) + json = JSONField(required=required, encrypted=encrypted) password = PasswordField(required=required, encrypted=encrypted) - relation = RelationField(required=required, encrypte=encrypted) - datetime = DatetimeField(required=required, encrypte=encrypted) - boolean = BoolField(required=required, encrypte=encrypted) + relation = RelationField(required=required, encrypted=encrypted) + datetime = DatetimeField(required=required, encrypted=encrypted) + boolean = BoolField(required=required, encrypted=encrypted) _required = required _global_ns = global_ns @@ -68,20 +68,12 @@ def model_serialized(related=None): ) -@pytest.fixture(params=itertools.product(*[ - [True, False], - [True, False], - [True, False] -])) +@pytest.fixture(params=itertools.product([True, False], repeat=3)) def get_object(request): return create_object(*request.param) -@pytest.fixture(params=itertools.product(*[ - [True, False], - [True, False], - [True, False] -])) +@pytest.fixture(params=itertools.product([True, False], repeat=3)) def get_model(request): return create_model(*request.param) @@ -452,13 +444,26 @@ def test_encryption_and_decryption(self, field_name, field_value): field = getattr(obj, '_{}'.format(field_name)) field.set_value(field_value) + assert field.encrypted # encryption encrypted = field.encrypt() - print(encrypted) + + assert isinstance(encrypted, str) # decryption field.set_value(None) field.decrypt(encrypted) assert field.value == field_value + + @pytest.mark.parametrize('field_name, field_value', model_kwargs.items()) + def test_serialized_model_dont_contain_value(self, field_name, field_value): + cls = create_model(False, False, True) + obj = cls(namespace, **model_kwargs) + + serialized = obj.serialize() + field = getattr(obj, '_{}'.format(field_name)) + + assert '"{}"'.format(field.value) not in serialized + assert '"{}"'.format(field.serialize()) not in serialized From 0abc420179315677a6a52c100cd8aceb605db238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kukr=C3=A1l?= Date: Thu, 11 Jan 2018 10:49:01 +0100 Subject: [PATCH 5/7] use asyncio for metric updates --- kqueen/blueprints/metrics/helpers.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/kqueen/blueprints/metrics/helpers.py b/kqueen/blueprints/metrics/helpers.py index 285878b9..591f1416 100644 --- a/kqueen/blueprints/metrics/helpers.py +++ b/kqueen/blueprints/metrics/helpers.py @@ -3,6 +3,7 @@ from kqueen.models import User from prometheus_client import Gauge +import asyncio import logging logger = logging.getLogger(__name__) @@ -21,7 +22,10 @@ def __init__(self): self.get_data() - def update_metrics(self): + async def update_metrics(self): + loop = asyncio.get_event_loop() + futures = [] + for metric_name, metric in metrics.items(): update_function_name = 'update_metric_{}'.format(metric_name) @@ -42,8 +46,12 @@ def update_metrics(self): raise Exception(msg) # run update function - # TODO: use asyncio for concurrent updates - fnc(metric) + future = loop.run_in_executor(None, fnc(metric)) + futures.append(future) + + # run all updates + for _ in await asyncio.gather(*futures): + pass def get_data(self): # users From 31829b64a57a535cf2fcb119d85b6edf612a4d5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kukr=C3=A1l?= Date: Thu, 11 Jan 2018 11:47:53 +0100 Subject: [PATCH 6/7] run metrics in dedicated executors --- kqueen/blueprints/metrics/helpers.py | 7 +++---- kqueen/blueprints/metrics/test_helpers.py | 25 ++++++++++++++++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/kqueen/blueprints/metrics/helpers.py b/kqueen/blueprints/metrics/helpers.py index 591f1416..e32e39ce 100644 --- a/kqueen/blueprints/metrics/helpers.py +++ b/kqueen/blueprints/metrics/helpers.py @@ -22,7 +22,7 @@ def __init__(self): self.get_data() - async def update_metrics(self): + def update_metrics(self): loop = asyncio.get_event_loop() futures = [] @@ -46,12 +46,11 @@ async def update_metrics(self): raise Exception(msg) # run update function - future = loop.run_in_executor(None, fnc(metric)) + future = loop.run_in_executor(None, fnc, metric) futures.append(future) # run all updates - for _ in await asyncio.gather(*futures): - pass + asyncio.wait(futures) def get_data(self): # users diff --git a/kqueen/blueprints/metrics/test_helpers.py b/kqueen/blueprints/metrics/test_helpers.py index 660ecd1d..7eb9d8cf 100644 --- a/kqueen/blueprints/metrics/test_helpers.py +++ b/kqueen/blueprints/metrics/test_helpers.py @@ -1,10 +1,25 @@ from .helpers import MetricUpdater from prometheus_client import generate_latest +import pytest -def test_dummy(user): - m = MetricUpdater() - m.update_metrics() - latest = generate_latest().decode('utf-8') - print(latest) +class TestMetricUpdates: + @pytest.fixture(scope='class') + def latest(self): + m = MetricUpdater() + m.update_metrics() + + return generate_latest().decode('utf-8') + + @pytest.mark.parametrize('metric, value', [ + ('users_by_namespace{namespace="demoorg"}', 1.0), + ('users_by_role{role="superadmin"}', 1.0), + ('users_active', 1.0), + ('organization_count', 1.0), + ]) + def test_metrics_exist(user, latest, metric, value): + + req = "{metric} {value}".format(metric=metric, value=value) + + assert req in latest From 08191ec0a0cce18e6421366377b84e74ff54d6cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kukr=C3=A1l?= Date: Thu, 11 Jan 2018 16:31:13 +0100 Subject: [PATCH 7/7] fix tests - metric numbers We can't easily read expected number of users and organizations because some tests may leave stale data and there stale resources are delete at the end of test suite. --- kqueen/blueprints/metrics/test_helpers.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/kqueen/blueprints/metrics/test_helpers.py b/kqueen/blueprints/metrics/test_helpers.py index 7eb9d8cf..eed41014 100644 --- a/kqueen/blueprints/metrics/test_helpers.py +++ b/kqueen/blueprints/metrics/test_helpers.py @@ -6,20 +6,22 @@ class TestMetricUpdates: @pytest.fixture(scope='class') - def latest(self): + def latest(self, user): + user.save() + m = MetricUpdater() m.update_metrics() return generate_latest().decode('utf-8') - @pytest.mark.parametrize('metric, value', [ - ('users_by_namespace{namespace="demoorg"}', 1.0), - ('users_by_role{role="superadmin"}', 1.0), - ('users_active', 1.0), - ('organization_count', 1.0), + @pytest.mark.parametrize('metric', [ + ('users_by_namespace{namespace="demoorg"}'), + ('users_by_role{role="superadmin"}'), + ('users_active'), + ('organization_count'), ]) - def test_metrics_exist(user, latest, metric, value): + def test_metrics_exist(user, latest, metric): - req = "{metric} {value}".format(metric=metric, value=value) + req = "{metric} ".format(metric=metric) assert req in latest