| # Licensed to the Apache Software Foundation (ASF) under one | |
| # or more contributor license agreements. See the NOTICE file | |
| # distributed with this work for additional information | |
| # regarding copyright ownership. The ASF licenses this file | |
| # to you under the Apache License, Version 2.0 (the | |
| # "License"); you may not use this file except in compliance | |
| # with the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, | |
| # software distributed under the License is distributed on an | |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| # KIND, either express or implied. See the License for the | |
| # specific language governing permissions and limitations | |
| # under the License. | |
| import time | |
| from abc import ABC, abstractmethod | |
| from dataclasses import dataclass | |
| from datetime import datetime, timezone | |
| from typing import Optional | |
| from urllib.parse import urljoin | |
| import requests | |
| from requests.adapters import HTTPAdapter | |
| from requests.exceptions import RequestException | |
| from pypaimon.api.client import ExponentialRetry | |
| from pypaimon.common import user_agent | |
| from pypaimon.common.options import Options | |
| from pypaimon.common.options.config import CatalogOptions | |
| from pypaimon.common.json_util import JSON, json_field, json_ignore_field | |
| @dataclass | |
| class DLFToken: | |
| TOKEN_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" | |
| access_key_id: str = json_field('AccessKeyId') | |
| access_key_secret: str = json_field('AccessKeySecret') | |
| security_token: Optional[str] = json_field('SecurityToken') | |
| expiration: Optional[str] = json_field('Expiration') | |
| expiration_at_millis: Optional[int] = json_ignore_field(default=None) | |
| @staticmethod | |
| def parse_expiration_to_millis(expiration: str) -> int: | |
| date_time = datetime.strptime(expiration, DLFToken.TOKEN_DATE_FORMAT) | |
| utc_datetime = date_time.replace(tzinfo=timezone.utc) | |
| expiration_at_millis = int(utc_datetime.timestamp() * 1000) | |
| return expiration_at_millis | |
| def __init__(self, access_key_id: str, access_key_secret: str, | |
| security_token: str, expiration: str = None, expiration_at_millis: int = None): | |
| self.access_key_id = access_key_id | |
| self.access_key_secret = access_key_secret | |
| self.security_token = security_token | |
| self.expiration = expiration | |
| if expiration_at_millis is not None: | |
| self.expiration_at_millis = expiration_at_millis | |
| if expiration is not None: | |
| self.expiration_at_millis = self.parse_expiration_to_millis(expiration) | |
| @classmethod | |
| def from_options(cls, options: Options) -> Optional['DLFToken']: | |
| from pypaimon.common.options.config import CatalogOptions | |
| if (options.get(CatalogOptions.DLF_ACCESS_KEY_ID) is None | |
| or options.get(CatalogOptions.DLF_ACCESS_KEY_SECRET) is None): | |
| return None | |
| else: | |
| return cls( | |
| access_key_id=options.get(CatalogOptions.DLF_ACCESS_KEY_ID), | |
| access_key_secret=options.get(CatalogOptions.DLF_ACCESS_KEY_SECRET), | |
| security_token=options.get(CatalogOptions.DLF_ACCESS_SECURITY_TOKEN) | |
| ) | |
| class DLFTokenLoader(ABC): | |
| @abstractmethod | |
| def load_token(self) -> DLFToken: | |
| pass | |
| @abstractmethod | |
| def description(self) -> str: | |
| pass | |
| class DLFLocalFileTokenLoader(DLFTokenLoader): | |
| """Load temporary DLF credentials from a local JSON file.""" | |
| DEFAULT_MAX_RETRIES = 5 | |
| def __init__(self, token_file_path: str): | |
| self.token_file_path = token_file_path | |
| def load_token(self) -> DLFToken: | |
| return self.read_token(self.token_file_path) | |
| def description(self) -> str: | |
| return self.token_file_path | |
| @staticmethod | |
| def read_token(token_file_path: str, | |
| max_retries: int = DEFAULT_MAX_RETRIES) -> DLFToken: | |
| retry = 1 | |
| last_exception = None | |
| while retry <= max_retries: | |
| try: | |
| with open(token_file_path, "r", encoding="utf-8") as token_file: | |
| token_json = token_file.read() | |
| except Exception as e: | |
| last_exception = RuntimeError( | |
| "Failed to read token file: {}".format(token_file_path) | |
| ) | |
| last_exception.__cause__ = e | |
| else: | |
| try: | |
| return JSON.from_json(token_json, DLFToken) | |
| except Exception: | |
| # The file contains AK/SK/STS. Do not retain the JSON | |
| # parser exception because it can include source content. | |
| last_exception = RuntimeError("Failed to parse token file.") | |
| if retry < max_retries: | |
| time.sleep(retry) | |
| retry += 1 | |
| raise last_exception | |
| class HTTPClient: | |
| """HTTP client with retry and timeout configuration""" | |
| def __init__(self, connect_timeout: int = 180, read_timeout: int = 180, | |
| max_retries: int = 3): | |
| self.connect_timeout = connect_timeout | |
| self.read_timeout = read_timeout | |
| self.session = requests.Session() | |
| self.session.headers['User-Agent'] = user_agent.rest_user_agent() | |
| # Add retry adapter | |
| retry_interceptor = ExponentialRetry(max_retries=3) | |
| adapter = HTTPAdapter(max_retries=retry_interceptor.adapter) | |
| self.session.mount("http://", adapter) | |
| self.session.mount("https://", adapter) | |
| # Set timeouts | |
| self.session.timeout = (connect_timeout, read_timeout) | |
| def get(self, url: str, **kwargs) -> requests.Response: | |
| """Make GET request with configured timeouts and retries""" | |
| return self.session.get(url, timeout=(self.connect_timeout, self.read_timeout), **kwargs) | |
| def close(self): | |
| """Close the session""" | |
| self.session.close() | |
| class DLFECSTokenLoader(DLFTokenLoader): | |
| """ | |
| DLF ECS Token Loader implementation | |
| This class loads DLF tokens from ECS metadata service. | |
| """ | |
| # Class-level HTTP client (equivalent to static in Java) | |
| _http_client: Optional[HTTPClient] = None | |
| @classmethod | |
| def get_http_client(cls) -> HTTPClient: | |
| """Get or create the shared HTTP client""" | |
| if cls._http_client is None: | |
| cls._http_client = HTTPClient( | |
| connect_timeout=180, # 3 minutes | |
| read_timeout=180, # 3 minutes | |
| max_retries=3 | |
| ) | |
| return cls._http_client | |
| def __init__(self, ecs_metadata_url: str, role_name: Optional[str] = None): | |
| """ | |
| Initialize DLF ECS Token Loader | |
| Args: | |
| ecs_metadata_url: ECS metadata service URL | |
| role_name: Optional role name. If None, will be fetched from metadata service | |
| """ | |
| self.ecs_metadata_url = ecs_metadata_url | |
| self.role_name = role_name | |
| def load_token(self) -> DLFToken: | |
| try: | |
| if self.role_name is None: | |
| self.role_name = self._get_role(self.ecs_metadata_url) | |
| token_url = urljoin(self.ecs_metadata_url.rstrip('/') + '/', self.role_name) | |
| return self._get_token(token_url) | |
| except Exception as e: | |
| raise RuntimeError("Token loading failed: {}".format(e)) from e | |
| def description(self) -> str: | |
| return self.ecs_metadata_url | |
| def _get_role(self, url: str) -> str: | |
| try: | |
| return self._get_response_body(url) | |
| except Exception as e: | |
| raise RuntimeError("Get role failed, error: {}".format(e)) from e | |
| def _get_token(self, url: str) -> DLFToken: | |
| try: | |
| token_json = self._get_response_body(url) | |
| return JSON.from_json(token_json, DLFToken) | |
| except OSError as e: | |
| # Python equivalent of UncheckedIOException | |
| raise OSError("IO error while getting token: {}".format(e)) from e | |
| except Exception as e: | |
| raise RuntimeError("Get token failed, error: {}".format(e)) from e | |
| def _get_response_body(self, url: str) -> str: | |
| try: | |
| http_client = self.get_http_client() | |
| response = http_client.get(url) | |
| if response is None: | |
| raise RuntimeError("Get response failed, response is None") | |
| if not response.ok: | |
| raise RuntimeError("Get response failed, response: {} {}".format( | |
| response.status_code, response.reason | |
| )) | |
| response_body = response.text | |
| if response_body is None: | |
| raise RuntimeError("Get response failed, response body is None") | |
| return response_body | |
| except RuntimeError: | |
| # Re-raise RuntimeError as-is | |
| raise | |
| except RequestException as e: | |
| raise RuntimeError("Request failed: {}".format(e)) from e | |
| except Exception as e: | |
| raise RuntimeError("Get response failed, error: {}".format(e)) from e | |
| # Factory and utility functions | |
| class DLFTokenLoaderFactory: | |
| """Factory for creating DLF token loaders""" | |
| @staticmethod | |
| def create_token_loader(options: Options) -> Optional['DLFTokenLoader']: | |
| """Create the configured token loader.""" | |
| loader = options.get(CatalogOptions.DLF_TOKEN_LOADER) | |
| if loader == 'ecs': | |
| ecs_metadata_url = options.get( | |
| CatalogOptions.DLF_TOKEN_ECS_METADATA_URL, | |
| 'http://100.100.100.200/latest/meta-data/Ram/security-credentials/' | |
| ) | |
| role_name = options.get(CatalogOptions.DLF_TOKEN_ECS_ROLE_NAME) | |
| return DLFECSTokenLoader(ecs_metadata_url, role_name) | |
| if loader == 'local_file': | |
| return DLFLocalFileTokenLoader( | |
| options.get(CatalogOptions.DLF_TOKEN_PATH) | |
| ) | |
| if loader is not None: | |
| raise ValueError("Unknown DLF token loader: {}".format(loader)) | |
| token_path = options.get(CatalogOptions.DLF_TOKEN_PATH) | |
| if token_path is not None: | |
| return DLFLocalFileTokenLoader(token_path) | |
| return None |