|
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__
|
| 25 | +from .compat import Lock |
23 | 26 | from .connection import Urllib3HttpConnection
|
24 | 27 | from .connection_pool import ConnectionPool, DummyConnectionPool, EmptyConnectionPool
|
25 | 28 | from .exceptions import (
|
| 29 | + AuthenticationException, |
| 30 | + AuthorizationException, |
26 | 31 | ConnectionError,
|
27 | 32 | ConnectionTimeout,
|
| 33 | + ElasticsearchWarning, |
| 34 | + NotElasticsearchError, |
28 | 35 | SerializationError,
|
29 | 36 | TransportError,
|
30 | 37 | )
|
@@ -198,6 +205,23 @@ def __init__(
|
198 | 205 | if http_client_meta:
|
199 | 206 | self._client_meta += (http_client_meta,)
|
200 | 207 |
|
| 208 | + # Tri-state flag that describes what state the verification |
| 209 | + # of whether we're connected to an Elasticsearch cluster or not. |
| 210 | + # The three states are: |
| 211 | + # - 'None': Means we've either not started the verification process |
| 212 | + # or that the verification is in progress. '_verified_once' ensures |
| 213 | + # that multiple requests don't kick off multiple verification processes. |
| 214 | + # - 'True': Means we've verified that we're talking to Elasticsearch or |
| 215 | + # that we can't rule out Elasticsearch due to auth issues. A warning |
| 216 | + # will be raised if we receive 401/403. |
| 217 | + # - 'False': Means we've discovered we're not talking to Elasticsearch, |
| 218 | + # should raise an error in this case for every request. |
| 219 | + self._verified_elasticsearch = None |
| 220 | + |
| 221 | + # Ensures that the ES verification request only fires once and that |
| 222 | + # all requests block until this request returns back. |
| 223 | + self._verified_once = Once() |
| 224 | + |
201 | 225 | def add_connection(self, host):
|
202 | 226 | """
|
203 | 227 | Create a new :class:`~elasticsearch.Connection` instance and add it to the pool.
|
@@ -380,6 +404,19 @@ def perform_request(self, method, url, headers=None, params=None, body=None):
|
380 | 404 | method, headers, params, body
|
381 | 405 | )
|
382 | 406 |
|
| 407 | + # Before we make the actual API call we verify the Elasticsearch instance. |
| 408 | + if self._verified_elasticsearch is None: |
| 409 | + self._verified_once.call( |
| 410 | + self._do_verify_elasticsearch, headers=headers, timeout=timeout |
| 411 | + ) |
| 412 | + |
| 413 | + # If '_verified_elasticsearch' is False we know we're not connected to Elasticsearch. |
| 414 | + if self._verified_elasticsearch is False: |
| 415 | + raise NotElasticsearchError( |
| 416 | + "The client noticed that the server is not Elasticsearch " |
| 417 | + "and we do not support this unknown product" |
| 418 | + ) |
| 419 | + |
383 | 420 | for attempt in range(self.max_retries + 1):
|
384 | 421 | connection = self.get_connection()
|
385 | 422 |
|
@@ -488,3 +525,132 @@ def _resolve_request_args(self, method, headers, params, body):
|
488 | 525 | )
|
489 | 526 |
|
490 | 527 | return method, headers, params, body, ignore, timeout
|
| 528 | + |
| 529 | + def _do_verify_elasticsearch(self, headers, timeout): |
| 530 | + """Verifies that we're connected to an Elasticsearch cluster. |
| 531 | + This is done at least once before the first actual API call |
| 532 | + and makes a single request to the 'GET /' API endpoint to |
| 533 | + check the version along with other details of the response. |
| 534 | +
|
| 535 | + If we're unable to verify we're talking to Elasticsearch |
| 536 | + but we're also unable to rule it out due to a permission |
| 537 | + error we instead emit an 'ElasticsearchWarning'. |
| 538 | + """ |
| 539 | + # Product check has already been done, no need to do again. |
| 540 | + if self._verified_elasticsearch is not None: |
| 541 | + return |
| 542 | + |
| 543 | + headers = {header.lower(): value for header, value in (headers or {}).items()} |
| 544 | + # We know we definitely want JSON so request it via 'accept' |
| 545 | + headers.setdefault("accept", "application/json") |
| 546 | + |
| 547 | + info_headers = {} |
| 548 | + info_response = {} |
| 549 | + error = None |
| 550 | + |
| 551 | + for conn in chain(self.connection_pool.connections, self.seed_connections): |
| 552 | + try: |
| 553 | + _, info_headers, info_response = conn.perform_request( |
| 554 | + "GET", "/", headers=headers, timeout=timeout |
| 555 | + ) |
| 556 | + |
| 557 | + # Lowercase all the header names for consistency in accessing them. |
| 558 | + info_headers = { |
| 559 | + header.lower(): value for header, value in info_headers.items() |
| 560 | + } |
| 561 | + |
| 562 | + info_response = self.deserializer.loads( |
| 563 | + info_response, mimetype="application/json" |
| 564 | + ) |
| 565 | + break |
| 566 | + |
| 567 | + # Previous versions of 7.x Elasticsearch required a specific |
| 568 | + # permission so if we receive HTTP 401/403 we should warn |
| 569 | + # instead of erroring out. |
| 570 | + except (AuthenticationException, AuthorizationException): |
| 571 | + warnings.warn( |
| 572 | + ( |
| 573 | + "The client is unable to verify that the server is " |
| 574 | + "Elasticsearch due security privileges on the server side" |
| 575 | + ), |
| 576 | + ElasticsearchWarning, |
| 577 | + stacklevel=5, |
| 578 | + ) |
| 579 | + self._verified_elasticsearch = True |
| 580 | + return |
| 581 | + |
| 582 | + # This connection didn't work, we'll try another. |
| 583 | + except (ConnectionError, SerializationError) as err: |
| 584 | + if error is None: |
| 585 | + error = err |
| 586 | + |
| 587 | + # If we received a connection error and weren't successful |
| 588 | + # anywhere then we reraise the more appropriate error. |
| 589 | + if error and not info_response: |
| 590 | + raise error |
| 591 | + |
| 592 | + # Check the information we got back from the index request. |
| 593 | + self._verified_elasticsearch = _verify_elasticsearch( |
| 594 | + info_headers, info_response |
| 595 | + ) |
| 596 | + |
| 597 | + |
| 598 | +def _verify_elasticsearch(headers, response): |
| 599 | + """Verifies that the server we're talking to is Elasticsearch. |
| 600 | + Does this by checking HTTP headers and the deserialized |
| 601 | + response to the 'info' API. Returns 'True' if we're verified |
| 602 | + against Elasticsearch, 'False' otherwise. |
| 603 | + """ |
| 604 | + try: |
| 605 | + version = response.get("version", {}) |
| 606 | + version_number = tuple( |
| 607 | + int(x) if x is not None else 999 |
| 608 | + for x in re.search( |
| 609 | + r"^([0-9]+)\.([0-9]+)(?:\.([0-9]+))?", version["number"] |
| 610 | + ).groups() |
| 611 | + ) |
| 612 | + except (KeyError, TypeError, ValueError, AttributeError): |
| 613 | + # No valid 'version.number' field, effectively 0.0.0 |
| 614 | + version = {} |
| 615 | + version_number = (0, 0, 0) |
| 616 | + |
| 617 | + # Check all of the fields and headers for missing/valid values. |
| 618 | + try: |
| 619 | + bad_tagline = response.get("tagline", None) != "You Know, for Search" |
| 620 | + bad_build_flavor = version.get("build_flavor", None) != "default" |
| 621 | + bad_product_header = headers.get("x-elastic-product", None) != "Elasticsearch" |
| 622 | + except (AttributeError, TypeError): |
| 623 | + bad_tagline = True |
| 624 | + bad_build_flavor = True |
| 625 | + bad_product_header = True |
| 626 | + |
| 627 | + if ( |
| 628 | + # No version or version less than 6.x |
| 629 | + version_number < (6, 0, 0) |
| 630 | + # 6.x and there's a bad 'tagline' |
| 631 | + or ((6, 0, 0) <= version_number < (7, 0, 0) and bad_tagline) |
| 632 | + # 7.0-7.13 and there's a bad 'tagline' or 'build_flavor' |
| 633 | + or ( |
| 634 | + (7, 0, 0) <= version_number < (7, 14, 0) |
| 635 | + and (bad_tagline or bad_build_flavor) |
| 636 | + ) |
| 637 | + # 7.14+ and there's a bad 'X-Elastic-Product' HTTP header |
| 638 | + or ((7, 14, 0) <= version_number and bad_product_header) |
| 639 | + ): |
| 640 | + return False |
| 641 | + |
| 642 | + return True |
| 643 | + |
| 644 | + |
| 645 | +class Once: |
| 646 | + """Simple class which forces a function to only execute once.""" |
| 647 | + |
| 648 | + def __init__(self): |
| 649 | + self._lock = Lock() |
| 650 | + self._called = False |
| 651 | + |
| 652 | + def call(self, func, *args, **kwargs): |
| 653 | + with self._lock: |
| 654 | + if not self._called: |
| 655 | + self._called = True |
| 656 | + func(*args, **kwargs) |
0 commit comments