|
| 1 | +# (C) Datadog, Inc. 2025-present |
| 2 | +# All rights reserved |
| 3 | +# Licensed under a 3-clause BSD style license (see LICENSE) |
| 4 | + |
| 5 | +""" |
| 6 | +The goal of these tests are to verify our default configuration values are correct and remain consistent. |
| 7 | +Failing tests indicate a regression in our defaults and should be looked inspected carefully. |
| 8 | +Default values are duplicated within this file by design to ensure tests fail if the value changes unexpectedly. |
| 9 | +""" |
| 10 | + |
| 11 | +from unittest.mock import MagicMock |
| 12 | + |
| 13 | +import pytest |
| 14 | + |
| 15 | +from datadog_checks.postgres.config import build_config |
| 16 | + |
| 17 | +# Single source of truth for all expected default values |
| 18 | +# Organized by category for readability |
| 19 | +EXPECTED_DEFAULTS = { |
| 20 | + # === Required fields (no defaults) === |
| 21 | + 'host': None, # Required, user must provide |
| 22 | + 'username': None, # Required, user must provide |
| 23 | + 'password': None, # Required, user must provide (optional for managed auth) |
| 24 | + # === Connection configuration === |
| 25 | + 'port': 5432, |
| 26 | + 'dbname': 'postgres', |
| 27 | + 'ssl': 'allow', |
| 28 | + 'ssl_root_cert': None, |
| 29 | + 'ssl_cert': None, |
| 30 | + 'ssl_key': None, |
| 31 | + 'ssl_password': None, |
| 32 | + 'query_timeout': 5000, |
| 33 | + 'idle_connection_timeout': 60000, |
| 34 | + 'max_connections': 30, |
| 35 | + 'application_name': 'datadog-agent', |
| 36 | + # === Identification === |
| 37 | + 'reported_hostname': None, |
| 38 | + 'exclude_hostname': False, |
| 39 | + 'data_directory': '/usr/local/pgsql/data', |
| 40 | + 'database_identifier': { |
| 41 | + 'template': '$resolved_hostname', |
| 42 | + }, |
| 43 | + # === Metric collection toggles === |
| 44 | + 'dbstrict': False, |
| 45 | + 'collect_function_metrics': False, |
| 46 | + 'collect_count_metrics': True, |
| 47 | + 'collect_checksum_metrics': False, |
| 48 | + 'collect_activity_metrics': False, |
| 49 | + 'collect_buffercache_metrics': False, |
| 50 | + 'collect_database_size_metrics': True, |
| 51 | + 'collect_default_database': True, |
| 52 | + 'collect_bloat_metrics': False, |
| 53 | + 'collect_wal_metrics': True, |
| 54 | + 'tag_replication_role': True, |
| 55 | + 'table_count_limit': 200, |
| 56 | + 'max_relations': 300, |
| 57 | + # === Database filtering === |
| 58 | + 'ignore_databases': [ |
| 59 | + 'template0', |
| 60 | + 'template1', |
| 61 | + 'rdsadmin', |
| 62 | + 'azure_maintenance', |
| 63 | + 'cloudsqladmin', |
| 64 | + 'alloydbadmin', |
| 65 | + 'alloydbmetadata', |
| 66 | + ], |
| 67 | + 'ignore_schemas_owned_by': [ |
| 68 | + 'rds_superuser', |
| 69 | + 'rdsadmin', |
| 70 | + ], |
| 71 | + # === Database monitoring (DBM) === |
| 72 | + 'dbm': False, |
| 73 | + 'pg_stat_statements_view': 'pg_stat_statements', |
| 74 | + 'pg_stat_activity_view': 'pg_stat_activity', |
| 75 | + 'log_unobfuscated_queries': False, |
| 76 | + 'log_unobfuscated_plans': False, |
| 77 | + 'database_instance_collection_interval': 300, |
| 78 | + # === DBM: Query metrics === |
| 79 | + 'query_metrics': { |
| 80 | + 'enabled': True, |
| 81 | + 'collection_interval': 10, |
| 82 | + 'pg_stat_statements_max_warning_threshold': 10000, |
| 83 | + 'incremental_query_metrics': False, |
| 84 | + 'baseline_metrics_expiry': 300, |
| 85 | + 'full_statement_text_cache_max_size': 10000, |
| 86 | + 'full_statement_text_samples_per_hour_per_query': 1, |
| 87 | + 'run_sync': False, |
| 88 | + }, |
| 89 | + # === DBM: Query samples === |
| 90 | + 'query_samples': { |
| 91 | + 'enabled': True, |
| 92 | + 'collection_interval': 1, |
| 93 | + 'explain_function': 'datadog.explain_statement', |
| 94 | + 'explained_queries_per_hour_per_query': 60, |
| 95 | + 'samples_per_hour_per_query': 15, |
| 96 | + 'explained_queries_cache_maxsize': 5000, |
| 97 | + 'seen_samples_cache_maxsize': 10000, |
| 98 | + 'explain_parameterized_queries': True, |
| 99 | + 'explain_errors_cache_maxsize': 5000, |
| 100 | + 'explain_errors_cache_ttl': 86400, |
| 101 | + 'run_sync': False, |
| 102 | + }, |
| 103 | + # === DBM: Query activity === |
| 104 | + 'query_activity': { |
| 105 | + 'enabled': True, |
| 106 | + 'collection_interval': 10, |
| 107 | + 'payload_row_limit': 3500, |
| 108 | + }, |
| 109 | + # === DBM: Settings collection === |
| 110 | + 'collect_settings': { |
| 111 | + 'enabled': True, |
| 112 | + 'collection_interval': 600, |
| 113 | + 'run_sync': False, |
| 114 | + 'ignored_settings_patterns': ['plpgsql%'], |
| 115 | + }, |
| 116 | + # === DBM: Schema collection === |
| 117 | + 'collect_schemas': { |
| 118 | + 'enabled': False, |
| 119 | + 'max_tables': 300, |
| 120 | + 'max_columns': 50, |
| 121 | + 'collection_interval': 600, |
| 122 | + 'max_query_duration': 60, |
| 123 | + }, |
| 124 | + # === DBM: Obfuscator options === |
| 125 | + 'obfuscator_options': { |
| 126 | + 'obfuscation_mode': 'obfuscate_and_normalize', |
| 127 | + 'replace_digits': False, |
| 128 | + 'collect_metadata': True, |
| 129 | + 'collect_tables': True, |
| 130 | + 'collect_commands': True, |
| 131 | + 'collect_comments': True, |
| 132 | + 'keep_sql_alias': True, |
| 133 | + 'keep_dollar_quoted_func': True, |
| 134 | + 'remove_space_between_parentheses': False, |
| 135 | + 'keep_null': False, |
| 136 | + 'keep_boolean': False, |
| 137 | + 'keep_positional_parameter': False, |
| 138 | + 'keep_trailing_semicolon': False, |
| 139 | + 'keep_identifier_quotation': False, |
| 140 | + 'keep_json_path': False, |
| 141 | + }, |
| 142 | + # === DBM: Database autodiscovery === |
| 143 | + 'database_autodiscovery': { |
| 144 | + 'enabled': False, |
| 145 | + 'global_view_db': 'postgres', |
| 146 | + 'max_databases': 100, |
| 147 | + 'refresh': 600, |
| 148 | + 'exclude': ['cloudsqladmin', 'rdsadmin', 'alloydbadmin', 'alloydbmetadata'], |
| 149 | + 'include': ['.*'], |
| 150 | + }, |
| 151 | + # === DBM: Lock metrics === |
| 152 | + 'locks_idle_in_transaction': { |
| 153 | + 'enabled': True, |
| 154 | + 'collection_interval': 300, |
| 155 | + 'max_rows': 100, |
| 156 | + }, |
| 157 | + # === DBM: Raw query statements === |
| 158 | + 'collect_raw_query_statement': { |
| 159 | + 'enabled': False, |
| 160 | + }, |
| 161 | + # === Relations configuration === |
| 162 | + 'relations': [], |
| 163 | + # === Query encodings === |
| 164 | + 'query_encodings': ['utf8'], |
| 165 | + # === Activity metrics === |
| 166 | + 'activity_metrics_excluded_aggregations': [], |
| 167 | + # === Cloud provider configurations === |
| 168 | + 'aws': { |
| 169 | + 'instance_endpoint': None, |
| 170 | + 'region': None, |
| 171 | + 'managed_authentication': {'enabled': None}, |
| 172 | + }, |
| 173 | + 'azure': { |
| 174 | + 'deployment_type': None, |
| 175 | + 'fully_qualified_domain_name': None, |
| 176 | + 'managed_authentication': {'enabled': None}, |
| 177 | + }, |
| 178 | + 'gcp': { |
| 179 | + 'project_id': None, |
| 180 | + 'instance_id': None, |
| 181 | + }, |
| 182 | + # === Tagging === |
| 183 | + 'tags': ('server:localhost', 'port:5432', 'db:postgres'), # Dynamically generated from connection info |
| 184 | + 'disable_generic_tags': False, |
| 185 | + 'propagate_agent_tags': False, |
| 186 | + # === Custom metrics/queries (deprecated/user-provided) === |
| 187 | + 'custom_metrics': (), # Deprecated field, defaults to empty tuple |
| 188 | + 'custom_queries': (), # User-provided queries, defaults to empty tuple |
| 189 | + 'only_custom_queries': False, # Flag to run only custom queries |
| 190 | + 'use_global_custom_queries': 'true', # Use custom queries from init_config |
| 191 | + 'service': None, # User-provided service name |
| 192 | + 'metric_patterns': None, # User-provided patterns |
| 193 | + # === Agent standard fields === |
| 194 | + 'min_collection_interval': 15.0, # Standard Agent field |
| 195 | + 'empty_default_hostname': False, # Deprecated field |
| 196 | +} |
| 197 | + |
| 198 | + |
| 199 | +@pytest.fixture |
| 200 | +def mock_check(): |
| 201 | + """Mock check object with a warning method.""" |
| 202 | + check = MagicMock() |
| 203 | + check.warning = MagicMock() |
| 204 | + return check |
| 205 | + |
| 206 | + |
| 207 | +@pytest.fixture |
| 208 | +def minimal_instance(): |
| 209 | + """Minimal instance configuration with only required fields.""" |
| 210 | + return { |
| 211 | + 'host': 'localhost', |
| 212 | + 'username': 'testuser', |
| 213 | + 'password': 'testpass', |
| 214 | + } |
| 215 | + |
| 216 | + |
| 217 | +pytestmark = pytest.mark.unit |
| 218 | + |
| 219 | + |
| 220 | +def test_all_config_defaults(mock_check, minimal_instance): |
| 221 | + """ |
| 222 | + Verify that all InstanceConfig fields have the expected default values. |
| 223 | +
|
| 224 | + This test iterates through every field in InstanceConfig and validates its default. |
| 225 | + If a field is missing from EXPECTED_DEFAULTS, the test will fail with instructions. |
| 226 | + """ |
| 227 | + from datadog_checks.postgres.config_models.instance import InstanceConfig |
| 228 | + |
| 229 | + # Build config with minimal instance |
| 230 | + mock_check.instance = minimal_instance |
| 231 | + mock_check.init_config = {} |
| 232 | + config, result = build_config(check=mock_check) |
| 233 | + |
| 234 | + # Get all fields from InstanceConfig |
| 235 | + all_fields = set(InstanceConfig.__annotations__.keys()) |
| 236 | + |
| 237 | + # Check for fields in InstanceConfig that aren't in EXPECTED_DEFAULTS |
| 238 | + missing_from_expected = all_fields - set(EXPECTED_DEFAULTS.keys()) |
| 239 | + if missing_from_expected: |
| 240 | + error_msg = ( |
| 241 | + f"\n\n{'=' * 80}\n" |
| 242 | + f"MISSING EXPECTED DEFAULTS!\n" |
| 243 | + f"{'=' * 80}\n\n" |
| 244 | + f"The following fields exist in InstanceConfig but are missing from EXPECTED_DEFAULTS:\n" |
| 245 | + f" {sorted(missing_from_expected)}\n\n" |
| 246 | + f"To fix this:\n" |
| 247 | + f"Add each field to the EXPECTED_DEFAULTS dictionary in test_config_defaults.py\n" |
| 248 | + f"with its expected default value.\n\n" |
| 249 | + f"Examples:\n" |
| 250 | + f" - Simple field: 'field_name': default_value,\n" |
| 251 | + f" - No default (user-provided): 'field_name': None,\n" |
| 252 | + f" - Nested object: 'field_name': {{'key': 'value'}},\n" |
| 253 | + f" - Array: 'field_name': ['item1', 'item2'],\n\n" |
| 254 | + f"{'=' * 80}\n" |
| 255 | + ) |
| 256 | + pytest.fail(error_msg) |
| 257 | + |
| 258 | + # Check for fields in EXPECTED_DEFAULTS that aren't in InstanceConfig (cleanup needed) |
| 259 | + extra_in_expected = set(EXPECTED_DEFAULTS.keys()) - all_fields |
| 260 | + if extra_in_expected: |
| 261 | + pytest.fail( |
| 262 | + f"\n\nThe following fields are in EXPECTED_DEFAULTS but don't exist in InstanceConfig:\n" |
| 263 | + f" {sorted(extra_in_expected)}\n" |
| 264 | + f"These should be removed from EXPECTED_DEFAULTS." |
| 265 | + ) |
| 266 | + |
| 267 | + # Required fields that come from minimal_instance - skip validation since they're user-provided |
| 268 | + SKIP_VALIDATION = {'host', 'username', 'password'} |
| 269 | + |
| 270 | + # Now validate each field's actual default against expected |
| 271 | + failures = [] |
| 272 | + |
| 273 | + for field_name, expected_value in EXPECTED_DEFAULTS.items(): |
| 274 | + if field_name in SKIP_VALIDATION: |
| 275 | + continue |
| 276 | + |
| 277 | + try: |
| 278 | + actual_value = getattr(config, field_name) |
| 279 | + except AttributeError: |
| 280 | + failures.append(f"{field_name}: Field not accessible on config object") |
| 281 | + continue |
| 282 | + |
| 283 | + # Handle different types of comparisons |
| 284 | + if isinstance(expected_value, dict): |
| 285 | + # For nested objects, recursively check fields |
| 286 | + if not _compare_nested_object(actual_value, expected_value, field_name, failures): |
| 287 | + continue |
| 288 | + elif isinstance(expected_value, (list, tuple)): |
| 289 | + # Convert actual value to same type for comparison |
| 290 | + if isinstance(actual_value, (list, tuple)): |
| 291 | + actual_as_type = type(expected_value)(actual_value) |
| 292 | + if actual_as_type != expected_value: |
| 293 | + failures.append(f"{field_name}: expected {expected_value!r}, got {actual_value!r}") |
| 294 | + elif actual_value is None: |
| 295 | + failures.append(f"{field_name}: expected {expected_value!r}, got None") |
| 296 | + else: |
| 297 | + failures.append(f"{field_name}: expected {expected_value!r}, got {actual_value!r}") |
| 298 | + else: |
| 299 | + # Simple value comparison |
| 300 | + if actual_value != expected_value: |
| 301 | + failures.append(f"{field_name}: expected {expected_value!r}, got {actual_value!r}") |
| 302 | + |
| 303 | + if failures: |
| 304 | + error_msg = ( |
| 305 | + f"\n\n{'=' * 80}\n" |
| 306 | + f"DEFAULT VALUE MISMATCHES DETECTED!\n" |
| 307 | + f"{'=' * 80}\n\n" |
| 308 | + f"The following fields have default values that don't match expectations:\n\n" |
| 309 | + + "\n".join(f" • {failure}" for failure in failures) |
| 310 | + + f"\n\n" |
| 311 | + f"This indicates either:\n" |
| 312 | + f" 1. A regression in default values (BAD - investigate carefully!)\n" |
| 313 | + f" 2. EXPECTED_DEFAULTS needs to be updated to match new behavior\n\n" |
| 314 | + f"{'=' * 80}\n" |
| 315 | + ) |
| 316 | + pytest.fail(error_msg) |
| 317 | + |
| 318 | + |
| 319 | +def _compare_nested_object(actual_obj, expected_dict, field_path, failures): |
| 320 | + """ |
| 321 | + Recursively compare a nested config object against expected dictionary. |
| 322 | +
|
| 323 | + Returns True if all comparisons passed, False if any failed (failures list is updated). |
| 324 | + """ |
| 325 | + all_passed = True |
| 326 | + |
| 327 | + for key, expected_value in expected_dict.items(): |
| 328 | + try: |
| 329 | + actual_value = getattr(actual_obj, key) |
| 330 | + except AttributeError: |
| 331 | + failures.append(f"{field_path}.{key}: Field not accessible") |
| 332 | + all_passed = False |
| 333 | + continue |
| 334 | + |
| 335 | + if isinstance(expected_value, dict): |
| 336 | + # Recursively check nested objects |
| 337 | + if not _compare_nested_object(actual_value, expected_value, f"{field_path}.{key}", failures): |
| 338 | + all_passed = False |
| 339 | + elif isinstance(expected_value, (list, tuple)): |
| 340 | + # Convert actual value to same type for comparison |
| 341 | + if isinstance(actual_value, (list, tuple)): |
| 342 | + actual_as_type = type(expected_value)(actual_value) |
| 343 | + if actual_as_type != expected_value: |
| 344 | + failures.append(f"{field_path}.{key}: expected {expected_value!r}, got {actual_value!r}") |
| 345 | + all_passed = False |
| 346 | + elif actual_value is None: |
| 347 | + failures.append(f"{field_path}.{key}: expected {expected_value!r}, got None") |
| 348 | + all_passed = False |
| 349 | + else: |
| 350 | + failures.append(f"{field_path}.{key}: expected {expected_value!r}, got {actual_value!r}") |
| 351 | + all_passed = False |
| 352 | + else: |
| 353 | + # Simple value comparison |
| 354 | + if actual_value != expected_value: |
| 355 | + failures.append(f"{field_path}.{key}: expected {expected_value!r}, got {actual_value!r}") |
| 356 | + all_passed = False |
| 357 | + |
| 358 | + return all_passed |
0 commit comments