|
15 | 15 | # specific language governing permissions and limitations
|
16 | 16 | # under the License.
|
17 | 17 |
|
| 18 | +import re |
18 | 19 | import time
|
| 20 | +import warnings |
19 | 21 | from itertools import chain
|
20 | 22 | from platform import python_version
|
21 | 23 |
|
22 | 24 | from ._version import __versionstr__
|
23 | 25 | from .connection import Urllib3HttpConnection
|
24 | 26 | from .connection_pool import ConnectionPool, DummyConnectionPool, EmptyConnectionPool
|
25 | 27 | from .exceptions import (
|
| 28 | + AuthenticationException, |
| 29 | + AuthorizationException, |
26 | 30 | ConnectionError,
|
27 | 31 | ConnectionTimeout,
|
| 32 | + ElasticsearchWarning, |
| 33 | + NotElasticsearchError, |
28 | 34 | SerializationError,
|
29 | 35 | TransportError,
|
30 | 36 | )
|
@@ -198,6 +204,10 @@ def __init__(
|
198 | 204 | if http_client_meta:
|
199 | 205 | self._client_meta += (http_client_meta,)
|
200 | 206 |
|
| 207 | + # Flag which is set after verifying that we're |
| 208 | + # connected to Elasticsearch. |
| 209 | + self._verified_elasticsearch = False |
| 210 | + |
201 | 211 | def add_connection(self, host):
|
202 | 212 | """
|
203 | 213 | Create a new :class:`~elasticsearch.Connection` instance and add it to the pool.
|
@@ -380,6 +390,9 @@ def perform_request(self, method, url, headers=None, params=None, body=None):
|
380 | 390 | method, headers, params, body
|
381 | 391 | )
|
382 | 392 |
|
| 393 | + # Before we make the actual API call we verify the Elasticsearch instance. |
| 394 | + self._do_verify_elasticsearch(headers=headers, timeout=timeout) |
| 395 | + |
383 | 396 | for attempt in range(self.max_retries + 1):
|
384 | 397 | connection = self.get_connection()
|
385 | 398 |
|
@@ -488,3 +501,115 @@ def _resolve_request_args(self, method, headers, params, body):
|
488 | 501 | )
|
489 | 502 |
|
490 | 503 | return method, headers, params, body, ignore, timeout
|
| 504 | + |
| 505 | + def _do_verify_elasticsearch(self, headers, timeout): |
| 506 | + """Verifies that we're connected to an Elasticsearch cluster. |
| 507 | + This is done at least once before the first actual API call |
| 508 | + and makes a single request to the 'GET /' API endpoint to |
| 509 | + check the version along with other details of the response. |
| 510 | +
|
| 511 | + If we're unable to verify we're talking to Elasticsearch |
| 512 | + but we're also unable to rule it out due to a permission |
| 513 | + error we instead emit an 'ElasticsearchWarning'. |
| 514 | + """ |
| 515 | + # Product check has already been done, no need to do again. |
| 516 | + if self._verified_elasticsearch: |
| 517 | + return |
| 518 | + |
| 519 | + headers = {header.lower(): value for header, value in (headers or {}).items()} |
| 520 | + # We know we definitely want JSON so request it via 'accept' |
| 521 | + headers.setdefault("accept", "application/json") |
| 522 | + |
| 523 | + info_headers = {} |
| 524 | + info_response = {} |
| 525 | + |
| 526 | + for conn in chain(self.connection_pool.connections, self.seed_connections): |
| 527 | + try: |
| 528 | + _, info_headers, info_response = conn.perform_request( |
| 529 | + "GET", "/", headers=headers, timeout=timeout |
| 530 | + ) |
| 531 | + |
| 532 | + # Lowercase all the header names for consistency in accessing them. |
| 533 | + info_headers = { |
| 534 | + header.lower(): value for header, value in info_headers.items() |
| 535 | + } |
| 536 | + |
| 537 | + info_response = self.deserializer.loads( |
| 538 | + info_response, mimetype="application/json" |
| 539 | + ) |
| 540 | + break |
| 541 | + |
| 542 | + # Previous versions of 7.x Elasticsearch required a specific |
| 543 | + # permission so if we receive HTTP 401/403 we should warn |
| 544 | + # instead of erroring out. |
| 545 | + except (AuthenticationException, AuthorizationException): |
| 546 | + warnings.warn( |
| 547 | + ( |
| 548 | + "The client is unable to verify that the server is " |
| 549 | + "Elasticsearch due security privileges on the server side" |
| 550 | + ), |
| 551 | + ElasticsearchWarning, |
| 552 | + stacklevel=3, |
| 553 | + ) |
| 554 | + self._verified_elasticsearch = True |
| 555 | + return |
| 556 | + |
| 557 | + # This connection didn't work, we'll try another. |
| 558 | + except (ConnectionError, SerializationError): |
| 559 | + pass |
| 560 | + |
| 561 | + # Check the information we got back from the index request. |
| 562 | + _verify_elasticsearch(info_headers, info_response) |
| 563 | + |
| 564 | + # If we made it through the above call this config is verified. |
| 565 | + self._verified_elasticsearch = True |
| 566 | + |
| 567 | + |
| 568 | +def _verify_elasticsearch(headers, response): |
| 569 | + """Verifies that the server we're talking to is Elasticsearch. |
| 570 | + Does this by checking HTTP headers and the deserialized |
| 571 | + response to the 'info' API. |
| 572 | +
|
| 573 | + If there's a problem this function raises 'NotElasticsearchError' |
| 574 | + otherwise doesn't do anything. |
| 575 | + """ |
| 576 | + try: |
| 577 | + version = response.get("version", {}) |
| 578 | + version_number = tuple( |
| 579 | + int(x) if x is not None else 999 |
| 580 | + for x in re.search( |
| 581 | + r"^([0-9]+)\.([0-9]+)(?:\.([0-9]+))?", version["number"] |
| 582 | + ).groups() |
| 583 | + ) |
| 584 | + except (KeyError, TypeError, ValueError, AttributeError): |
| 585 | + # No valid 'version.number' field, effectively 0.0.0 |
| 586 | + version = {} |
| 587 | + version_number = (0, 0, 0) |
| 588 | + |
| 589 | + # Check all of the fields and headers for missing/valid values. |
| 590 | + try: |
| 591 | + bad_tagline = response.get("tagline", None) != "You Know, for Search" |
| 592 | + bad_build_flavor = version.get("build_flavor", None) != "default" |
| 593 | + bad_product_header = headers.get("x-elastic-product", None) != "Elasticsearch" |
| 594 | + except (AttributeError, TypeError): |
| 595 | + bad_tagline = True |
| 596 | + bad_build_flavor = True |
| 597 | + bad_product_header = True |
| 598 | + |
| 599 | + if ( |
| 600 | + # No version or version less than 6.x |
| 601 | + version_number < (6, 0, 0) |
| 602 | + # 6.x and there's a bad 'tagline' |
| 603 | + or ((6, 0, 0) <= version_number < (7, 0, 0) and bad_tagline) |
| 604 | + # 7.0-7.13 and there's a bad 'tagline' or 'build_flavor' |
| 605 | + or ( |
| 606 | + (7, 0, 0) <= version_number < (7, 14, 0) |
| 607 | + and (bad_tagline or bad_build_flavor) |
| 608 | + ) |
| 609 | + # 7.14+ and there's a bad 'X-Elastic-Product' HTTP header |
| 610 | + or ((7, 14, 0) <= version_number and bad_product_header) |
| 611 | + ): |
| 612 | + raise NotElasticsearchError( |
| 613 | + "The client noticed that the server is not Elasticsearch " |
| 614 | + "and we do not support this unknown product" |
| 615 | + ) |
0 commit comments