Skip to content

Commit 0f5123e

Browse files
committed
Add Postgres remote query proof executor
1 parent 48a7ac4 commit 0f5123e

4 files changed

Lines changed: 537 additions & 0 deletions

File tree

postgres/changelog.d/23476.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add a remote query POC executor for Postgres.
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under Simplified BSD License (see LICENSE)
4+
5+
from __future__ import annotations
6+
7+
import json
8+
import logging
9+
from collections.abc import Iterable, Mapping, Sequence
10+
from dataclasses import dataclass
11+
from typing import TYPE_CHECKING, Any, Protocol
12+
13+
if TYPE_CHECKING:
14+
from datadog_checks.postgres import PostgreSql
15+
16+
LOGGER = logging.getLogger(__name__)
17+
18+
_ALLOWED_QUERY = 'SELECT 1 AS value'
19+
# POC/M2 guardrail while the executor accepts raw Mapping[str, Any] requests; Data Observability
20+
# query-actions relies on typed RC payloads plus cloned Agent-local Postgres config instead, so
21+
# keep this recursive denylist conservative and do not treat it as final production validation.
22+
_CREDENTIAL_FIELD_NAMES = {
23+
'access_token',
24+
'connection_string',
25+
'connectionstring',
26+
'dsn',
27+
'passwd',
28+
'password',
29+
'pwd',
30+
'refresh_token',
31+
'ssl_cert',
32+
'ssl_key',
33+
'ssl_password',
34+
'ssl_root_cert',
35+
'sslcert',
36+
'sslkey',
37+
'sslpassword',
38+
'sslrootcert',
39+
'token',
40+
'url',
41+
'user',
42+
'username',
43+
}
44+
45+
46+
@dataclass(frozen=True)
47+
class RemoteQueryTarget:
48+
host: str
49+
port: int
50+
dbname: str
51+
52+
53+
@dataclass(frozen=True)
54+
class RemoteQueryLimits:
55+
max_rows: int = 10
56+
max_bytes: int = 1_048_576
57+
timeout_ms: int = 5_000
58+
59+
60+
@dataclass(frozen=True)
61+
class StaticPostgresCheckRegistry:
62+
checks: Sequence['PostgreSql']
63+
64+
def iter_postgres_checks(self) -> Iterable['PostgreSql']:
65+
return iter(self.checks)
66+
67+
68+
class PostgresCheckRegistry(Protocol):
69+
def iter_postgres_checks(self) -> Iterable['PostgreSql']: ...
70+
71+
72+
def execute_remote_query(request: Mapping[str, Any], registry: PostgresCheckRegistry) -> dict[str, Any]:
73+
if not isinstance(request, Mapping):
74+
return _error('invalid_request', 'Remote query request must be a mapping.')
75+
76+
if _contains_credential_field(request):
77+
LOGGER.warning('Rejected remote query request containing credential-shaped fields')
78+
return _error('request_contains_credentials', 'Request must not contain datastore credential material.')
79+
80+
target_or_error = _parse_target(request.get('target'))
81+
if isinstance(target_or_error, dict):
82+
return target_or_error
83+
target = target_or_error
84+
85+
if not _is_allowed_query(request.get('query')):
86+
return _error('query_rejected', 'Only the canonical SELECT 1 proof query is allowed.')
87+
88+
limits_or_error = _parse_limits(request.get('limits', {}))
89+
if isinstance(limits_or_error, dict):
90+
return limits_or_error
91+
limits = limits_or_error
92+
93+
matches = _resolve_matches(target, registry.iter_postgres_checks())
94+
LOGGER.debug('Remote query target match count: %d', len(matches))
95+
if not matches:
96+
return _error('target_not_found', 'No loaded Postgres integration instance matched target selector.')
97+
if len(matches) > 1:
98+
return _error('target_ambiguous', 'More than one loaded Postgres integration instance matched target selector.')
99+
100+
return _execute_select_1(matches[0], target, limits)
101+
102+
103+
def normalize_target(target: Mapping[str, Any]) -> RemoteQueryTarget:
104+
host = target.get('host')
105+
if not isinstance(host, str) or not host.strip():
106+
raise ValueError('host must be a non-empty string')
107+
108+
dbname = target.get('dbname')
109+
if not isinstance(dbname, str) or not dbname:
110+
raise ValueError('dbname must be a non-empty string')
111+
if dbname != dbname.strip():
112+
raise ValueError('dbname must not contain surrounding whitespace')
113+
114+
return RemoteQueryTarget(host=_normalize_host(host), port=_normalize_port(target.get('port', 5432)), dbname=dbname)
115+
116+
117+
def _parse_target(value: Any) -> RemoteQueryTarget | dict[str, Any]:
118+
if not isinstance(value, Mapping):
119+
return _error('invalid_selector', 'Target selector must be a mapping.')
120+
121+
try:
122+
return normalize_target(value)
123+
except ValueError as e:
124+
return _error('invalid_selector', str(e))
125+
126+
127+
def _parse_limits(value: Any) -> RemoteQueryLimits | dict[str, Any]:
128+
if value is None:
129+
value = {}
130+
if not isinstance(value, Mapping):
131+
return _error('invalid_request', 'Limits must be a mapping.')
132+
133+
try:
134+
return RemoteQueryLimits(
135+
max_rows=_positive_int(value.get('maxRows', 10), 'maxRows'),
136+
max_bytes=_positive_int(value.get('maxBytes', 1_048_576), 'maxBytes'),
137+
timeout_ms=_positive_int(value.get('timeoutMs', 5_000), 'timeoutMs'),
138+
)
139+
except ValueError as e:
140+
return _error('invalid_request', str(e))
141+
142+
143+
def _resolve_matches(target: RemoteQueryTarget, checks: Iterable['PostgreSql']) -> list['PostgreSql']:
144+
matches = []
145+
for check in checks:
146+
config = getattr(check, '_config', None)
147+
if config is None:
148+
continue
149+
try:
150+
candidate = RemoteQueryTarget(
151+
host=_normalize_host(config.host),
152+
port=_normalize_port(config.port),
153+
dbname=config.dbname,
154+
)
155+
except (AttributeError, ValueError):
156+
continue
157+
if candidate == target:
158+
matches.append(check)
159+
return matches
160+
161+
162+
def _execute_select_1(check: 'PostgreSql', target: RemoteQueryTarget, limits: RemoteQueryLimits) -> dict[str, Any]:
163+
db_pool = getattr(check, 'db_pool', None)
164+
if db_pool is None:
165+
return _error('credentials_unavailable', 'Matched Postgres check does not expose a connection pool.')
166+
if getattr(db_pool, 'is_closed', lambda: False)():
167+
return _error('target_unavailable', 'Matched Postgres check connection pool is closed.', retryable=False)
168+
169+
try:
170+
with db_pool.get_connection(target.dbname) as conn:
171+
with conn.cursor() as cursor:
172+
cursor.execute(_ALLOWED_QUERY)
173+
if cursor.description is None:
174+
return _error('query_failed', 'Query did not return a result set.')
175+
columns = [_column_name(column) for column in cursor.description]
176+
raw_rows = cursor.fetchmany(limits.max_rows + 1)
177+
except RuntimeError:
178+
return _error('target_unavailable', 'Matched Postgres check connection pool is unavailable.', retryable=False)
179+
except Exception:
180+
LOGGER.exception('Remote query execution failed')
181+
return _error('query_failed', 'Remote query execution failed.')
182+
183+
truncated = len(raw_rows) > limits.max_rows
184+
rows = [_row_to_dict(columns, row) for row in raw_rows[: limits.max_rows]]
185+
response_columns = [{'name': name, 'type': _infer_type(rows, name)} for name in columns]
186+
bytes_returned = len(json.dumps({'columns': response_columns, 'rows': rows}, default=str).encode('utf-8'))
187+
188+
return {
189+
'status': 'SUCCEEDED',
190+
'columns': response_columns,
191+
'rows': rows,
192+
'truncated': truncated,
193+
'stats': {'rowCount': len(rows), 'bytesReturned': bytes_returned},
194+
}
195+
196+
197+
def _contains_credential_field(value: Any) -> bool:
198+
if isinstance(value, Mapping):
199+
for key, nested_value in value.items():
200+
if str(key).lower() in _CREDENTIAL_FIELD_NAMES:
201+
return True
202+
if _contains_credential_field(nested_value):
203+
return True
204+
elif isinstance(value, list | tuple):
205+
return any(_contains_credential_field(item) for item in value)
206+
return False
207+
208+
209+
def _is_allowed_query(value: Any) -> bool:
210+
if not isinstance(value, str):
211+
return False
212+
return value.strip().rstrip(';').strip() == _ALLOWED_QUERY
213+
214+
215+
def _normalize_host(value: str) -> str:
216+
host = value.strip().lower()
217+
if host.endswith('.'):
218+
host = host[:-1]
219+
if not host:
220+
raise ValueError('host must be a non-empty string')
221+
return host
222+
223+
224+
def _normalize_port(value: Any) -> int:
225+
if isinstance(value, bool):
226+
raise ValueError('port must be an integer')
227+
if isinstance(value, int):
228+
port = value
229+
elif isinstance(value, str):
230+
if not value.isdigit():
231+
raise ValueError('port must be an integer')
232+
port = int(value)
233+
else:
234+
raise ValueError('port must be an integer')
235+
236+
if port <= 0 or port > 65535:
237+
raise ValueError('port must be between 1 and 65535')
238+
return port
239+
240+
241+
def _positive_int(value: Any, field: str) -> int:
242+
if isinstance(value, bool):
243+
raise ValueError(f'{field} must be a positive integer')
244+
if isinstance(value, int):
245+
number = value
246+
elif isinstance(value, str) and value.isdigit():
247+
number = int(value)
248+
else:
249+
raise ValueError(f'{field} must be a positive integer')
250+
if number <= 0:
251+
raise ValueError(f'{field} must be a positive integer')
252+
return number
253+
254+
255+
def _column_name(column: Any) -> str:
256+
name = getattr(column, 'name', None)
257+
if name is not None:
258+
return str(name)
259+
return str(column[0])
260+
261+
262+
def _row_to_dict(columns: list[str], row: Any) -> dict[str, Any]:
263+
if isinstance(row, Mapping):
264+
return {column: row[column] for column in columns}
265+
return dict(zip(columns, row))
266+
267+
268+
def _infer_type(rows: list[dict[str, Any]], column: str) -> str:
269+
for row in rows:
270+
value = row.get(column)
271+
if isinstance(value, bool):
272+
return 'boolean'
273+
if isinstance(value, int):
274+
return 'integer'
275+
if isinstance(value, float):
276+
return 'number'
277+
if value is not None:
278+
return 'string'
279+
return 'unknown'
280+
281+
282+
def _error(code: str, message: str, retryable: bool = False) -> dict[str, Any]:
283+
return {'status': 'FAILED', 'error': {'code': code, 'message': message, 'retryable': retryable}}

0 commit comments

Comments
 (0)