Skip to content

Commit 41788f9

Browse files
author
Yann MAHE
committed
[wmi] friendly com_errors 💑
Maintain a list of "known" WMI `com_errors`, and translate them to user intelligible errors. Add a `mute` parameter to `WMISampler`. * If set to `True` (default for system checks), `com_error` are "translated" (when possible), logged and do not cause any interruption. * If not (user checks), "translated" exceptions raise.
1 parent 5a82dce commit 41788f9

File tree

4 files changed

+223
-21
lines changed

4 files changed

+223
-21
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""
2+
List WMI "known" `com_errors` errors.
3+
4+
Translate to user intelligible exceptions.
5+
"""
6+
7+
_user_exception_by_com_errors = {}
8+
9+
10+
def com_error(error_id):
11+
"""
12+
A decorator that assigns an `error_id` to an intelligible exception.
13+
"""
14+
def set_exception(exception):
15+
_user_exception_by_com_errors[error_id] = exception
16+
return exception
17+
return set_exception
18+
19+
20+
def raise_on_com_error(error, silent=True):
21+
"""
22+
Raise the user exception associated with the given `com_error` or
23+
fall back to the original exception.
24+
"""
25+
raise _user_exception_by_com_errors.get(error[0], error)
26+
27+
28+
# List of user exceptions
29+
class WMIException(Exception):
30+
"""
31+
Abtract exception for WMI.
32+
"""
33+
def __init__(self):
34+
"""
35+
Use the class docstring as an exception message.
36+
"""
37+
super(WMIException, self).__init__(self.__doc__)
38+
39+
40+
@com_error(-2147352567)
41+
class WMIProviderNotSupported(WMIException):
42+
"""WMI `provider` option is not supported on the system."""
43+
pass
44+
45+
46+
@com_error(-2147217392)
47+
class WMIInvalidClass(WMIException):
48+
"""WMI class is invalid."""
49+
pass

‎checks/libs/wmi/sampler.py‎

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
# project
3232
from checks.libs.wmi.counter_type import get_calculator, get_raw, UndefinedCalculator
33+
from checks.libs.wmi.exceptions import raise_on_com_error, WMIException
3334
from utils.timeout import timeout, TimeoutException
3435

3536

@@ -91,8 +92,10 @@ class WMISampler(object):
9192

9293
def __init__(self, logger, class_name, property_names, filters="", host="localhost",
9394
namespace="root\\cimv2", provider=None,
94-
username="", password="", and_props=[], timeout_duration=10):
95+
username="", password="", and_props=[],
96+
mute=True, timeout_duration=10):
9597
self.logger = logger
98+
self.mute = mute
9699

97100
# Connection information
98101
self.host = host
@@ -424,13 +427,12 @@ def build_where_clause(fltr):
424427
more=build_where_clause(fltr)
425428
)
426429

427-
428430
if not filters:
429431
return ""
430432

431433
return " WHERE {clause}".format(clause=build_where_clause(filters))
432434

433-
def _query(self): # pylint: disable=E0202
435+
def _query(self): # pylint: disable=E0202
434436
"""
435437
Query WMI using WMI Query Language (WQL) & parse the results.
436438
@@ -444,31 +446,50 @@ def _query(self): # pylint: disable=E0202
444446
)
445447
self.logger.debug(u"Querying WMI: {0}".format(wql))
446448

447-
try:
448-
# From: https://msdn.microsoft.com/en-us/library/aa393866(v=vs.85).aspx
449-
flag_return_immediately = 0x10 # Default flag.
450-
flag_forward_only = 0x20
451-
flag_use_amended_qualifiers = 0x20000
449+
# From: https://msdn.microsoft.com/en-us/library/aa393866(v=vs.85).aspx
450+
flag_return_immediately = 0x10 # Default flag.
451+
flag_forward_only = 0x20
452+
flag_use_amended_qualifiers = 0x20000
452453

453-
query_flags = flag_return_immediately | flag_forward_only
454+
query_flags = flag_return_immediately | flag_forward_only
454455

455-
# For the first query, cache the qualifiers to determine each
456-
# propertie's "CounterType"
457-
includes_qualifiers = self.is_raw_perf_class and self._property_counter_types is None
458-
if includes_qualifiers:
459-
self._property_counter_types = CaseInsensitiveDict()
460-
query_flags |= flag_use_amended_qualifiers
456+
# For the first query, cache the qualifiers to determine each
457+
# propertie's "CounterType"
458+
includes_qualifiers = self.is_raw_perf_class and self._property_counter_types is None
459+
if includes_qualifiers:
460+
self._property_counter_types = CaseInsensitiveDict()
461+
query_flags |= flag_use_amended_qualifiers
461462

463+
try:
462464
raw_results = self.get_connection().ExecQuery(wql, "WQL", query_flags)
463-
464-
results = self._parse_results(raw_results, includes_qualifiers=includes_qualifiers)
465-
466-
except pywintypes.com_error:
467-
self.logger.warning(u"Failed to execute WMI query (%s)", wql, exc_info=True)
465+
except pywintypes.com_error as e:
466+
self._handle_com_error(e, wql)
468467
results = []
468+
else:
469+
results = self._parse_results(raw_results, includes_qualifiers=includes_qualifiers)
469470

470471
return results
471472

473+
def _handle_com_error(self, error, wql):
474+
"""
475+
Attempt to translate the WMI `com_error` to something intelligible.
476+
Raise when needed or log a warning.
477+
"""
478+
warning_template = u"Failed to execute WMI query ({}).".format(wql)
479+
480+
try:
481+
raise_on_com_error(error)
482+
483+
# Translate to user exceptions
484+
except WMIException as e:
485+
if not self.mute:
486+
raise
487+
self.logger.warning(u"%s Reason:%s", warning_template, e.message)
488+
489+
# Unknown exceptions
490+
except Exception:
491+
self.logger.exception(warning_template)
492+
472493
def _parse_results(self, raw_results, includes_qualifiers):
473494
"""
474495
Parse WMI query results in a more comprehensive form.

‎tests/core/test_wmi.py‎

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
from mock import Mock, patch
1010

1111
# project
12+
from checks.libs.wmi.exceptions import WMIInvalidClass
1213
from tests.checks.common import Fixtures
14+
from tests.core.test_wmi_exceptions import MockedWMICOMError
1315
from utils.timeout import TimeoutException
1416

1517

@@ -349,7 +351,34 @@ def assertInPartial(self, first, second):
349351
350352
Note: needs to be defined for Python 2.6
351353
"""
352-
self.assertTrue(any(key for key in second if key.startswith(first)), "{0} not in {1}".format(first, second))
354+
self.assertTrue(
355+
any(key for key in second if key.startswith(first)),
356+
"{0} not in {1}".format(first, second)
357+
)
358+
359+
def _assertLogger(self, logger, message, level):
360+
"""
361+
Assert that the logger was called with the given level and submessage.
362+
"""
363+
logger_method = getattr(logger, level)
364+
365+
# Logger was called with the given level
366+
self.assertTrue(logger_method.called)
367+
368+
# Submessage was logged
369+
self.assertTrue([s for s in logger_method.call_args[0] if message in s])
370+
371+
def assertWarning(self, *args):
372+
"""
373+
Assert logging with the WARNING level.
374+
"""
375+
self._assertLogger(*args, level="warning")
376+
377+
def assertException(self, *args):
378+
"""
379+
Assert logging with the EXCEPTION level.
380+
"""
381+
self._assertLogger(*args, level="exception")
353382

354383
def getProp(self, dict, prefix):
355384
"""
@@ -788,6 +817,54 @@ def test_missing_property(self):
788817

789818
self.assertWMISampler(wmi_raw_sampler, ["MissingProperty"], count=1)
790819

820+
def test_warnings_on_com_errors(self):
821+
"""
822+
Log a warning on WMI `com_errors`.
823+
"""
824+
# Mute to WMI Sampler
825+
from checks.libs.wmi.sampler import WMISampler
826+
logger = Mock()
827+
wmi_sampler = WMISampler(logger, "WMI_Class", ["Property"], mute=True)
828+
829+
# Samples
830+
invalid_class_com_error = MockedWMICOMError(-2147217392)
831+
unknown_com_error = MockedWMICOMError(123456)
832+
833+
# Method to test
834+
log_on_com_errors = partial(wmi_sampler._handle_com_error, wql="")
835+
836+
# Log WARNING an intelligible when possible
837+
log_on_com_errors(invalid_class_com_error)
838+
self.assertWarning(logger, "class is invalid")
839+
840+
# Or log ERROR the exception trace
841+
log_on_com_errors(unknown_com_error)
842+
self.assertException(logger, "Failed to execute WMI query")
843+
844+
def test_raise_on_com_errors(self):
845+
"""
846+
Log a warning on WMI `com_errors`.
847+
"""
848+
# Do not mute to WMI Sampler
849+
from checks.libs.wmi.sampler import WMISampler
850+
logger = Mock()
851+
wmi_sampler = WMISampler(logger, "WMI_Class", ["Property"], mute=False)
852+
853+
# Samples
854+
invalid_class_com_error = MockedWMICOMError(-2147217392)
855+
unknown_com_error = MockedWMICOMError(123456)
856+
857+
# Method to test
858+
raise_on_com_errors = partial(wmi_sampler._handle_com_error, wql="")
859+
860+
# Raise known exceptions
861+
with self.assertRaises(WMIInvalidClass):
862+
raise_on_com_errors(invalid_class_com_error)
863+
864+
# Log exceptions the others
865+
raise_on_com_errors(unknown_com_error)
866+
self.assertException(logger, "Failed to execute WMI query")
867+
791868

792869
class TestIntegrationWMI(unittest.TestCase):
793870
"""
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# stdlib
2+
import unittest
3+
4+
# datadog
5+
from checks.libs.wmi.exceptions import com_error, raise_on_com_error
6+
7+
8+
class MockedWMICOMError(Exception):
9+
"""
10+
Mocking a WMI `com_error` error.
11+
"""
12+
def __init__(self, error_id):
13+
super(MockedWMICOMError, self).__init__()
14+
self.error_id = error_id
15+
16+
def __getitem__(self, index):
17+
if index == 0:
18+
return self.error_id
19+
20+
raise NotImplementedError
21+
22+
23+
class TestWMIExceptions(unittest.TestCase):
24+
"""
25+
Unit testing for WMI `com_error` errors and user exceptions.
26+
"""
27+
def test_register_com_error(self):
28+
"""
29+
`com_error` decorator register and map a WMI `com_error` to an user exception.
30+
"""
31+
# Mock a WMI `com_error`
32+
sample_error = MockedWMICOMError(123456)
33+
34+
# Map it to an user exception
35+
@com_error(sample_error.error_id)
36+
class WMISampleException(Exception):
37+
"""
38+
Sample WMI exception.
39+
"""
40+
pass
41+
42+
# Assert that the exception is raised
43+
with self.assertRaises(WMISampleException):
44+
raise_on_com_error(sample_error)
45+
46+
def test_unregistred_com_error(self):
47+
"""
48+
Unknown `com_error` errors remain intact.
49+
"""
50+
# Mock a WMI `com_error`, do not register it
51+
sample_error = MockedWMICOMError(456789)
52+
53+
# Assert that the original exception is raised
54+
with self.assertRaises(MockedWMICOMError):
55+
raise_on_com_error(sample_error)

0 commit comments

Comments
 (0)