diff --git a/.travis.yml b/.travis.yml index e052c32dd57e2..24589d0f6d02e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -163,7 +163,7 @@ jobs: - stage: test env: CHECK=mesos_master - stage: test - env: CHECK=mongo + env: CHECK=mongo PYTHON3=true - stage: test env: CHECK=mysql - stage: test diff --git a/mongo/datadog_checks/mongo/mongo.py b/mongo/datadog_checks/mongo/mongo.py index 8a5a145909b8e..eb3d4bb667ed0 100644 --- a/mongo/datadog_checks/mongo/mongo.py +++ b/mongo/datadog_checks/mongo/mongo.py @@ -1,16 +1,18 @@ -# stdlib +# (C) Datadog, Inc. 2018 +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) import re import time -import urllib +from distutils.version import LooseVersion -# 3p import pymongo +from six import PY3, iteritems, itervalues +from six.moves.urllib.parse import unquote_plus, urlsplit -# project -from datadog_checks.checks import AgentCheck -from urlparse import urlsplit -from datadog_checks.config import _is_affirmative -from distutils.version import LooseVersion +from datadog_checks.base import AgentCheck, is_affirmative + +if PY3: + long = int DEFAULT_TIMEOUT = 30 GAUGE = AgentCheck.gauge @@ -302,8 +304,8 @@ class MongoDb(AgentCheck): "wiredTiger.cache.maximum bytes configured": GAUGE, "wiredTiger.cache.maximum page size at eviction": GAUGE, "wiredTiger.cache.modified pages evicted": GAUGE, - "wiredTiger.cache.pages read into cache": GAUGE, # noqa - "wiredTiger.cache.pages written from cache": GAUGE, # noqa + "wiredTiger.cache.pages read into cache": GAUGE, # noqa + "wiredTiger.cache.pages written from cache": GAUGE, # noqa "wiredTiger.cache.pages currently held in the cache": (GAUGE, "wiredTiger.cache.pages_currently_held_in_cache"), # noqa "wiredTiger.cache.pages evicted because they exceeded the in-memory maximum": (RATE, "wiredTiger.cache.pages_evicted_exceeding_the_in-memory_maximum"), # noqa "wiredTiger.cache.pages evicted by application threads": RATE, @@ -416,11 +418,12 @@ def __init__(self, name, init_config, agentConfig, instances=None): self.metrics_to_collect_by_instance = {} self.collection_metrics_names = [] - for (key, value) in self.COLLECTION_METRICS.iteritems(): + for key, value in iteritems(self.COLLECTION_METRICS): self.collection_metrics_names.append(key.split('.')[1]) - def get_library_versions(self): - return {"pymongo": pymongo.version} + @classmethod + def get_library_versions(cls): + return {'pymongo': pymongo.version} def get_state_description(self, state): if state in self.REPLSET_MEMBER_STATES: @@ -434,7 +437,7 @@ def get_state_name(self, state): else: return 'UNKNOWN' - def _report_replica_set_state(self, state, clean_server_name, replset_name, agentConfig): + def _report_replica_set_state(self, state, clean_server_name, replset_name): """ Report the member's replica set state * Submit a service check. @@ -443,9 +446,9 @@ def _report_replica_set_state(self, state, clean_server_name, replset_name, agen last_state = self._last_state_by_server.get(clean_server_name, -1) self._last_state_by_server[clean_server_name] = state if last_state != state and last_state != -1: - return self.create_event(last_state, state, clean_server_name, replset_name, agentConfig) + return self.create_event(last_state, state, clean_server_name, replset_name) - def hostname_for_event(self, clean_server_name, agentConfig): + def hostname_for_event(self, clean_server_name): """Return a reasonable hostname for a replset membership event to mention.""" uri = urlsplit(clean_server_name) if '@' in uri.netloc: @@ -456,14 +459,14 @@ def hostname_for_event(self, clean_server_name, agentConfig): hostname = self.hostname return hostname - def create_event(self, last_state, state, clean_server_name, replset_name, agentConfig): + def create_event(self, last_state, state, clean_server_name, replset_name): """Create an event with a message describing the replication state of a mongo node""" status = self.get_state_description(state) short_status = self.get_state_name(state) last_short_status = self.get_state_name(last_state) - hostname = self.hostname_for_event(clean_server_name, agentConfig) + hostname = self.hostname_for_event(clean_server_name) msg_title = "%s is %s for %s" % (hostname, short_status, replset_name) msg = "MongoDB %s (%s) just reported as %s (%s) for %s; it was %s before." msg = msg % (hostname, clean_server_name, status, short_status, replset_name, last_short_status) @@ -489,7 +492,7 @@ def _build_metric_list_to_collect(self, additional_metrics): metrics_to_collect = {} # Defaut metrics - for default_metrics in self.DEFAULT_METRICS.itervalues(): + for default_metrics in itervalues(self.DEFAULT_METRICS): metrics_to_collect.update(default_metrics) # Additional metrics metrics @@ -552,7 +555,7 @@ def _normalize(self, metric_name, submit_method, prefix): metric_suffix = "ps" if submit_method == RATE else "" # Replace case-sensitive metric name characters - for pattern, repl in self.CASE_SENSITIVE_METRIC_NAME_SUFFIXES.iteritems(): + for pattern, repl in iteritems(self.CASE_SENSITIVE_METRIC_NAME_SUFFIXES): metric_name = re.compile(pattern).sub(repl, metric_name) # Normalize, and wrap @@ -602,7 +605,8 @@ def _authenticate(self, database, username, password, use_x509, server_name, ser return authenticated - def _parse_uri(self, server, sanitize_username=False): + @classmethod + def _parse_uri(cls, server, sanitize_username=False): """ Parses a MongoDB-formatted URI (e.g. mongodb://user:pass@server/db) and returns parsed elements and a sanitized URI. @@ -618,7 +622,7 @@ def _parse_uri(self, server, sanitize_username=False): # Remove password (and optionally username) from sanitized server URI. # To ensure that the `replace` works well, we first need to url-decode the raw server string # since the password parsed by pymongo is url-decoded - decoded_server = urllib.unquote_plus(server) + decoded_server = unquote_plus(server) clean_server_name = decoded_server.replace(password, "*" * 5) if password else decoded_server if sanitize_username and username: @@ -675,7 +679,7 @@ def total_seconds(td): 'ssl_ca_certs': instance.get('ssl_ca_certs', None) } - for key, param in ssl_params.items(): + for key, param in list(iteritems(ssl_params)): if param is None: del ssl_params[key] @@ -781,16 +785,14 @@ def total_seconds(td): status['fsyncLocked'] = 1 if ops.get('fsyncLock') else 0 status['stats'] = db.command('dbstats') - dbstats = {} - dbstats[db_name] = {'stats': status['stats']} + dbstats = {db_name: {'stats': status['stats']}} # Handle replica data, if any # See # http://www.mongodb.org/display/DOCS/Replica+Set+Commands#ReplicaSetCommands-replSetGetStatus # noqa - if _is_affirmative(instance.get('replica_check', True)): + if is_affirmative(instance.get('replica_check', True)): try: data = {} - dbnames = [] replSet = admindb.command('replSetGetStatus') if replSet: @@ -864,7 +866,7 @@ def total_seconds(td): # Submit events self._report_replica_set_state( - data['state'], clean_server_name, replset_name, self.agentConfig + data['state'], clean_server_name, replset_name ) except Exception as e: @@ -916,7 +918,7 @@ def total_seconds(td): submit_method, metric_name_alias = self._resolve_metric(metric_name, metrics_to_collect) submit_method(self, metric_name_alias, value, tags=tags) - for st, value in dbstats.iteritems(): + for st, value in iteritems(dbstats): for metric_name in metrics_to_collect: if not metric_name.startswith('stats.'): continue @@ -946,7 +948,7 @@ def total_seconds(td): self._resolve_metric(metric_name, metrics_to_collect) submit_method(self, metric_name_alias, val, tags=metrics_tags) - if _is_affirmative(instance.get('collections_indexes_stats')): + if is_affirmative(instance.get('collections_indexes_stats')): mongo_version = cli.server_info().get('version', '0.0') if LooseVersion(mongo_version) >= LooseVersion("3.2"): self._collect_indexes_stats(instance, db, tags) @@ -958,7 +960,7 @@ def total_seconds(td): if 'top' in additional_metrics: try: dbtop = db.command('top') - for ns, ns_metrics in dbtop['totals'].iteritems(): + for ns, ns_metrics in iteritems(dbtop['totals']): if "." not in ns: continue @@ -1032,7 +1034,7 @@ def total_seconds(td): # encountered an error trying to access options.size for the oplog collection self.log.warning(u"Failed to record `ReplicationInfo` metrics.") - for (m, value) in oplog_data.iteritems(): + for m, value in iteritems(oplog_data): submit_method, metric_name_alias = \ self._resolve_metric('oplog.%s' % m, metrics_to_collect) submit_method(self, metric_name_alias, value, tags=tags) @@ -1062,7 +1064,7 @@ def total_seconds(td): submit_method, metric_name_alias = \ self._resolve_metric('collection.%s' % m, self.COLLECTION_METRICS) # loop through the indexes - for (idx, val) in value.iteritems(): + for idx, val in iteritems(value): # we tag the index idx_tags = coll_tags + ["index:%s" % idx] submit_method(self, metric_name_alias, val, tags=idx_tags) diff --git a/mongo/setup.py b/mongo/setup.py index 07f51c579d5a9..e2a813a265de9 100644 --- a/mongo/setup.py +++ b/mongo/setup.py @@ -23,7 +23,7 @@ def get_requirements(fpath): return f.readlines() -CHECKS_BASE_REQ = 'datadog_checks_base' +CHECKS_BASE_REQ = 'datadog-checks-base>=4.2.0' setup( name='datadog-mongo', diff --git a/mongo/tests/common.py b/mongo/tests/common.py index 47a4f566a0f76..23f4de78afe6e 100644 --- a/mongo/tests/common.py +++ b/mongo/tests/common.py @@ -1,5 +1,9 @@ +# (C) Datadog, Inc. 2018 +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) import os -from datadog_checks.utils.common import get_docker_hostname + +from datadog_checks.dev import get_docker_hostname HOST = get_docker_hostname() PORT1 = 27017 @@ -7,12 +11,6 @@ MAX_WAIT = 150 MONGODB_SERVER = "mongodb://%s:%s/test" % (HOST, PORT1) -MONGODB_SERVER2 = "mongodb://%s:%s/test" % (HOST, PORT2) - - -MONGODB_CONFIG = { - 'server': MONGODB_SERVER -} HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(os.path.dirname(HERE)) diff --git a/mongo/tests/compose/docker-compose.yml b/mongo/tests/compose/docker-compose.yml index 4016a1c65e771..aefca6b4d9fff 100644 --- a/mongo/tests/compose/docker-compose.yml +++ b/mongo/tests/compose/docker-compose.yml @@ -1,4 +1,6 @@ version: '2' + +# This was heavily based upon: https://github.com/chefsplate/mongo-shard-docker-compose services: ## Config Servers diff --git a/mongo/tests/compose/init.sh b/mongo/tests/compose/init.sh deleted file mode 100755 index 297e2216e8830..0000000000000 --- a/mongo/tests/compose/init.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -docker-compose -f "$DOCKER_COMPOSE_FILE" exec -T config01 sh -c "mongo --port 27017 < /scripts/init-configserver.js" -docker-compose -f "$DOCKER_COMPOSE_FILE" exec -T shard01a sh -c "mongo --port 27018 < /scripts/init-shard01.js" -docker-compose -f "$DOCKER_COMPOSE_FILE" exec -T shard02a sh -c "mongo --port 27019 < /scripts/init-shard02.js" -docker-compose -f "$DOCKER_COMPOSE_FILE" exec -T shard03a sh -c "mongo --port 27020 < /scripts/init-shard03.js" -sleep 20 -docker-compose -f "$DOCKER_COMPOSE_FILE" exec -T router sh -c "mongo < /scripts/init-router.js" diff --git a/mongo/tests/compose/readme.md b/mongo/tests/compose/readme.md deleted file mode 100644 index 3bbd44e0b72f9..0000000000000 --- a/mongo/tests/compose/readme.md +++ /dev/null @@ -1 +0,0 @@ -This was heavily based upon this: https://github.com/chefsplate/mongo-shard-docker-compose diff --git a/mongo/tests/conftest.py b/mongo/tests/conftest.py index 73c00ef1997fb..9762ce38ee34f 100644 --- a/mongo/tests/conftest.py +++ b/mongo/tests/conftest.py @@ -1,121 +1,97 @@ -# (C) Datadog, Inc. 2010-2017 +# (C) Datadog, Inc. 2018 # All rights reserved -# Licensed under Simplified BSD License (see LICENSE) -import subprocess +# Licensed under a 3-clause BSD style license (see LICENSE) import os -import logging import time import pymongo import pytest +from datadog_checks.dev import LazyFunction, WaitFor, docker_run, run_command from datadog_checks.mongo import MongoDb - from . import common -log = logging.getLogger('conftest') +@pytest.fixture(scope='session') +def dd_environment(instance): + compose_file = os.path.join(common.HERE, 'compose', 'docker-compose.yml') + + with docker_run( + compose_file, + conditions=[ + WaitFor(setup_sharding, args=(compose_file, ), attempts=5, wait=5), + InitializeDB(), + ], + ): + yield instance -@pytest.fixture -def check(): - check = MongoDb('mongo', {}, {}) - return check +@pytest.fixture(scope='session') +def instance(): + return { + 'server': common.MONGODB_SERVER, + } -@pytest.fixture(scope="session") -def set_up_mongo(): - cli = pymongo.mongo_client.MongoClient( - common.MONGODB_SERVER, - socketTimeoutMS=30000, - read_preference=pymongo.ReadPreference.PRIMARY_PREFERRED,) - foos = [] - for _ in range(70): - foos.append({'1': []}) - foos.append({'1': []}) - foos.append({}) +@pytest.fixture +def instance_user(): + return { + 'server': 'mongodb://testUser2:testPass2@{}:{}/test'.format(common.HOST, common.PORT1), + } - bars = [] - for _ in range(50): - bars.append({'1': []}) - bars.append({}) - db = cli['test'] - db.foo.insert_many(foos) - db.bar.insert_many(bars) +@pytest.fixture +def instance_authdb(): + return { + 'server': 'mongodb://testUser:testPass@{}:{}/test?authSource=authDB'.format(common.HOST, common.PORT1), + } - authDB = cli['authDB'] - authDB.command("createUser", 'testUser', pwd='testPass', roles=[{'role': 'read', 'db': 'test'}]) - db.command("createUser", 'testUser2', pwd='testPass2', roles=[{'role': 'read', 'db': 'test'}]) +@pytest.fixture +def check(): + return MongoDb('mongo', {}, {}) - yield - tear_down_mongo() +def setup_sharding(compose_file): + service_commands = [ + ('config01', 'mongo --port 27017 < /scripts/init-configserver.js'), + ('shard01a', 'mongo --port 27018 < /scripts/init-shard01.js'), + ('shard02a', 'mongo --port 27019 < /scripts/init-shard02.js'), + ('shard03a', 'mongo --port 27020 < /scripts/init-shard03.js'), + ('router', 'mongo < /scripts/init-router.js'), + ] -def tear_down_mongo(): - cli = pymongo.mongo_client.MongoClient( - common.MONGODB_SERVER, - socketTimeoutMS=30000, - read_preference=pymongo.ReadPreference.PRIMARY_PREFERRED,) + for i, (service, command) in enumerate(service_commands, 1): + # Wait before router init + if i == len(service_commands): + time.sleep(20) - db = cli['test'] - db.drop_collection("foo") - db.drop_collection("bar") + run_command(['docker-compose', '-f', compose_file, 'exec', '-T', service, 'sh', '-c', command], check=True) -@pytest.fixture(scope="session") -def spin_up_mongo(): - """ - Start a cluster with one master, one replica and one unhealthy replica and - stop it after the tests are done. - If there's any problem executing docker-compose, let the exception bubble - up. - """ +class InitializeDB(LazyFunction): + def __call__(self): + cli = pymongo.mongo_client.MongoClient( + common.MONGODB_SERVER, + socketTimeoutMS=30000, + read_preference=pymongo.ReadPreference.PRIMARY_PREFERRED, ) - env = os.environ + foos = [] + for _ in range(70): + foos.append({'1': []}) + foos.append({'1': []}) + foos.append({}) - compose_file = os.path.join(common.HERE, 'compose', 'docker-compose.yml') + bars = [] + for _ in range(50): + bars.append({'1': []}) + bars.append({}) - env['DOCKER_COMPOSE_FILE'] = compose_file + db = cli['test'] + db.foo.insert_many(foos) + db.bar.insert_many(bars) - args = [ - "docker-compose", - "-f", compose_file - ] + auth_db = cli['authDB'] + auth_db.command("createUser", 'testUser', pwd='testPass', roles=[{'role': 'read', 'db': 'test'}]) - try: - subprocess.check_call(args + ["up", "-d"], env=env) - setup_sharding(env=env) - except Exception: - cleanup_mongo(args, env) - raise - - yield - cleanup_mongo(args, env) - - -def setup_sharding(env=None): - curdir = os.getcwd() - compose_dir = os.path.join(common.HERE, 'compose') - os.chdir(compose_dir) - for i in xrange(5): - try: - subprocess.check_call(['bash', 'init.sh'], env=env) - os.chdir(curdir) - return - except Exception as e: - log.info(e) - time.sleep(5) - - os.chdir(curdir) - raise e - - -def cleanup_mongo(args, env): - subprocess.check_call(args + ["down"], env=env) - # it creates a lot of volumes, this is necessary - try: - subprocess.check_call(['docker', 'volume', 'prune', '-f']) - except Exception: - pass + db.command("createUser", 'testUser2', pwd='testPass2', roles=[{'role': 'read', 'db': 'test'}]) diff --git a/mongo/tests/test_mongo.py b/mongo/tests/test_mongo.py index 5febb92cc3534..f208ace921057 100644 --- a/mongo/tests/test_mongo.py +++ b/mongo/tests/test_mongo.py @@ -1,10 +1,12 @@ -import common +# (C) Datadog, Inc. 2018 +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from . import common -from types import ListType +import pytest +from six import itervalues -import logging - -log = logging.getLogger('test_mongo') +from datadog_checks.dev.utils import ensure_bytes METRIC_VAL_CHECKS = { 'mongodb.asserts.msgps': lambda x: x >= 0, @@ -42,20 +44,16 @@ } -def test_mongo(spin_up_mongo, aggregator, set_up_mongo, check): - - instance = { - 'server': "mongodb://testUser:testPass@%s:%s/test?authSource=authDB" % (common.HOST, common.PORT1) - } - +@pytest.mark.usefixtures('dd_environment') +def test_mongo(aggregator, check, instance_authdb): # Run the check against our running server - check.check(instance) + check.check(instance_authdb) # Metric assertions - metrics = aggregator._metrics.values() + metrics = list(itervalues(aggregator._metrics)) assert metrics - assert isinstance(metrics, ListType) + assert isinstance(metrics, list) assert len(metrics) > 0 for m in metrics: @@ -65,26 +63,27 @@ def test_mongo(spin_up_mongo, aggregator, set_up_mongo, check): assert METRIC_VAL_CHECKS[metric_name](metric.value) -def test_mongo2(spin_up_mongo, aggregator, set_up_mongo, check): - instance = { - 'server': "mongodb://testUser2:testPass2@%s:%s/test" % (common.HOST, common.PORT1) - } +@pytest.mark.usefixtures('dd_environment') +def test_mongo2(aggregator, check, instance_user): # Run the check against our running server - check.check(instance) + check.check(instance_user) # Service checks - service_checks = aggregator._service_checks.values() + service_checks = list(itervalues(aggregator._service_checks)) service_checks_count = len(service_checks) assert service_checks_count > 0 assert len(service_checks[0]) == 1 # Assert that all service checks have the proper tags: host and port for sc in service_checks[0]: - assert "host:%s" % common.HOST in sc.tags - assert "port:%s" % common.PORT1 in sc.tags or "port:%s" % common.PORT2 in sc.tags - assert "db:test" in sc.tags + assert ensure_bytes('host:{}'.format(common.HOST)) in sc.tags + assert ( + ensure_bytes('port:{}'.format(common.PORT1)) in sc.tags + or ensure_bytes('port:{}'.format(common.PORT2)) in sc.tags + ) + assert b'db:test' in sc.tags # Metric assertions - metrics = aggregator._metrics.values() + metrics = list(itervalues(aggregator._metrics)) assert metrics assert len(metrics) > 0 @@ -95,18 +94,15 @@ def test_mongo2(spin_up_mongo, aggregator, set_up_mongo, check): assert METRIC_VAL_CHECKS[metric_name](metric.value) -def test_mongo_old_config(spin_up_mongo, aggregator, set_up_mongo, check): - instance = { - 'server': "mongodb://%s:%s/test" % (common.HOST, common.PORT1) - } - +@pytest.mark.usefixtures('dd_environment') +def test_mongo_old_config(aggregator, check, instance): # Run the check against our running server check.check(instance) # Metric assertions - metrics = aggregator._metrics.values() + metrics = list(itervalues(aggregator._metrics)) assert metrics - assert isinstance(metrics, ListType) + assert isinstance(metrics, list) assert len(metrics) > 0 for m in metrics: @@ -116,17 +112,15 @@ def test_mongo_old_config(spin_up_mongo, aggregator, set_up_mongo, check): assert METRIC_VAL_CHECKS_OLD[metric_name](metric.value) -def test_mongo_old_config_2(spin_up_mongo, aggregator, set_up_mongo, check): - instance = { - 'server': "mongodb://%s:%s/test" % (common.HOST, common.PORT1) - } +@pytest.mark.usefixtures('dd_environment') +def test_mongo_old_config_2(aggregator, check, instance): # Run the check against our running server check.check(instance) # Metric assertions - metrics = aggregator._metrics.values() + metrics = list(itervalues(aggregator._metrics)) assert metrics - assert isinstance(metrics, ListType) + assert isinstance(metrics, list) assert len(metrics) > 0 for m in metrics: diff --git a/mongo/tests/test_unit.py b/mongo/tests/test_unit.py index 6e7b8b168ba07..953ce472c8206 100644 --- a/mongo/tests/test_unit.py +++ b/mongo/tests/test_unit.py @@ -1,10 +1,12 @@ -# (C) Datadog, Inc. 2010-2017 +# (C) Datadog, Inc. 2018 # All rights reserved -# Licensed under Simplified BSD License (see LICENSE) +# Licensed under a 3-clause BSD style license (see LICENSE) import logging import mock import pytest +from six import iteritems + from datadog_checks.mongo import MongoDb log = logging.getLogger('test_mongo') @@ -29,7 +31,7 @@ def test_build_metric_list(check): m_name: m_type for d in [ check.BASE_METRICS, check.DURABILITY_METRICS, check.LOCKS_METRICS, check.WIREDTIGER_METRICS, ] - for m_name, m_type in d.iteritems() + for m_name, m_type in iteritems(d) } # No option diff --git a/mongo/tox.ini b/mongo/tox.ini index 6f8187e5200db..de4e71fd200be 100644 --- a/mongo/tox.ini +++ b/mongo/tox.ini @@ -2,10 +2,9 @@ minversion = 2.0 basepython = py27 envlist = + {3.2.10,3.4} + py{27,36}-{3.5} unit - mongo3.4 - mongo3.5 - mongo3.2.10 flake8 [testenv] @@ -19,32 +18,16 @@ deps = -rrequirements-dev.txt commands = pip install --require-hashes -r requirements.txt - pytest -v -m"not unit" --log-level=debug + pytest -v -m"not unit" +setenv = + 3.2.10: MONGO_VERSION=3.2.10 + 3.4: MONGO_VERSION=3.4 + 3.5: MONGO_VERSION=3.5 [testenv:unit] commands = pip install --require-hashes -r requirements.txt - pytest -v -m"unit" --log-level=debug - -[testenv:mongo2.6.9] -setenv = - MONGO_VERSION=2.6.9 - -[testenv:mongo3.0.1] -setenv = - MONGO_VERSION=3.0.1 - -[testenv:mongo3.2.10] -setenv = - MONGO_VERSION=3.2.10 - -[testenv:mongo3.4] -setenv = - MONGO_VERSION=3.4 - -[testenv:mongo3.5] -setenv = - MONGO_VERSION=3.5 + pytest -v -m"unit" [testenv:flake8] skip_install = true