diff --git a/README.md b/README.md index 9d2c457..f80c212 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ Flask-приложение для автотестов сервисов Nubes. } ``` +Для PROD автоопределения дополнительно укажи `NUBES_API_PROD_ENDPOINT` с подтверждённым URL PROD API. Значение не задаётся по умолчанию; без него доступны только DEV/TEST из списка в `site/api/http_client.py`. + ## Структура ``` diff --git a/site/api/auth.py b/site/api/auth.py index ca98666..564930f 100644 --- a/site/api/auth.py +++ b/site/api/auth.py @@ -41,9 +41,17 @@ def _cached_detect(token): if cache is None: g._detected_endpoint = cache = {} if token not in cache: - endpoint = current_app.config["NUBES_API_ENDPOINT"] - if endpoint in STANDS: - endpoint = detect_endpoint(token) or endpoint + endpoint = current_app.config["NUBES_API_ENDPOINT"].rstrip("/") + endpoints = [url.rstrip("/") for url in STANDS] + for configured_endpoint in ( + current_app.config.get("NUBES_API_PROD_ENDPOINT", ""), + endpoint, + ): + configured_endpoint = configured_endpoint.rstrip("/") + if configured_endpoint and configured_endpoint not in endpoints: + endpoints.append(configured_endpoint) + if endpoint in endpoints: + endpoint = detect_endpoint(token, endpoints=endpoints) or endpoint cache[token] = endpoint return cache[token] @@ -137,11 +145,12 @@ def get_stand(): return f"polygon_{get_polygon_stand()}" token = get_token() - endpoint = current_app.config["NUBES_API_ENDPOINT"] - if endpoint not in STANDS: - s = stand_name(endpoint) - return s if s != "?" else "mock" - return stand_name(_cached_detect(token)) + endpoint = _cached_detect(token).rstrip("/") + prod_endpoint = current_app.config.get("NUBES_API_PROD_ENDPOINT", "").rstrip("/") + if prod_endpoint and endpoint == prod_endpoint: + return "prod" + s = stand_name(endpoint) + return s if s != "?" else "mock" def get_token_info(): diff --git a/site/api/http_client.py b/site/api/http_client.py index 89b120f..f26ad50 100644 --- a/site/api/http_client.py +++ b/site/api/http_client.py @@ -10,12 +10,12 @@ HttpClient: - raw_delete: DELETE без авторизации (для CMDB) Функции автоопределения стенда: - - detect_endpoint(token) — пробует dev→test стенды по токену - - stand_name(endpoint) — "dev" / "test" по URL + - detect_endpoint(token) — пробует настроенные API стенды по токену + - stand_name(endpoint) — "dev" / "test" по URL - create_client(token) — HttpClient + endpoint одним вызовом -Зачем автоопределение: пользователь вводит токен, мы не знаем dev это или test. -Пробуем оба стенда — какой ответит с results != None, тот и рабочий. +Зачем автоопределение: пользователь вводит токен, мы не знаем его стенд. +Пробуем настроенные URL — какой ответит с results != None, тот и рабочий. """ import requests @@ -28,8 +28,8 @@ STANDS = [ ] -def detect_endpoint(token): - """Пробуем токен против dev и test стендов, возвращаем рабочий URL. +def detect_endpoint(token, endpoints=None): + """Пробуем токен против заданных API стендов, возвращаем рабочий URL. Алгоритм: 1. Для каждого стенда создаём HttpClient + делаем GET /instances?pageSize=1. @@ -40,7 +40,7 @@ def detect_endpoint(token): Почему results != None а не просто HTTP 200: API может вернуть 200 с пустым списком (нет инстансов) — это норм. results=None — признак что ответ не соответствует ожидаемой структуре.""" - for ep in STANDS: + for ep in endpoints or STANDS: try: c = HttpClient(ep, token) data = c.get("/instances", params={"pageSize": 1, "page": 1}) diff --git a/site/app.py b/site/app.py index 4bf0dec..d34869b 100644 --- a/site/app.py +++ b/site/app.py @@ -32,7 +32,7 @@ from routes.api_scenario_defs import bp_defs as api_scenario_defs_bp # Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода. # Нужна для фильтрации истории (пользователь видит только записи своей версии). -VERSION = "1.2.48" +VERSION = "1.2.49" # Flask-приложение с Jinja2-шаблонами из папки templates/ app = Flask(__name__, template_folder="templates", static_folder="static") @@ -44,6 +44,7 @@ app.config["NUBES_API_ENDPOINT"] = os.getenv( ) app.config["NUBES_API_TOKEN"] = os.getenv("NUBES_API_TOKEN", "") app.config["POLYGON_ENDPOINT"] = os.getenv("POLYGON_ENDPOINT", "") # если задан → все запросы в polygon +app.config["NUBES_API_PROD_ENDPOINT"] = os.getenv("NUBES_API_PROD_ENDPOINT", "") app.config["VERSION"] = VERSION # Регистрируем blueprint'ы — каждый отвечает за свою группу маршрутов diff --git a/tests/test_auth.py b/tests/test_auth.py index 4408f28..83029cf 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -123,3 +123,64 @@ def test_whitelist_stand_invalid(monkeypatch): })() })()) assert auth.get_polygon_stand() == "test" # fallback + + +def test_cloud_detection_includes_configured_prod_endpoint(monkeypatch): + from flask import Flask + + prod_endpoint = "https://prod.example/api/v1/svc" + app = Flask(__name__) + app.config["NUBES_API_ENDPOINT"] = auth.STANDS[1] + app.config["NUBES_API_PROD_ENDPOINT"] = prod_endpoint + captured = {} + + def fake_detect(token, endpoints): + captured["token"] = token + captured["endpoints"] = endpoints + return prod_endpoint + + monkeypatch.setattr(auth, "detect_endpoint", fake_detect) + monkeypatch.setattr(auth, "current_app", app) + with app.test_request_context("/"): + assert auth._cached_detect("prod-token") == prod_endpoint + + assert captured["token"] == "prod-token" + assert captured["endpoints"] == [*auth.STANDS, prod_endpoint] + + +def test_detect_endpoint_tries_configured_prod_endpoint(monkeypatch): + from api import http_client + + prod_endpoint = "https://prod.example/api/v1/svc" + endpoints = [*auth.STANDS, prod_endpoint] + attempted = [] + + class FakeHttpClient: + def __init__(self, endpoint, token): + self.endpoint = endpoint + + def get(self, path, **kwargs): + attempted.append(self.endpoint) + if self.endpoint != prod_endpoint: + raise RuntimeError("token belongs to another stand") + return {"results": []} + + monkeypatch.setattr(http_client, "HttpClient", FakeHttpClient) + + assert http_client.detect_endpoint("prod-token", endpoints=endpoints) == prod_endpoint + assert attempted == endpoints + + +def test_get_stand_labels_configured_prod_endpoint(monkeypatch): + from flask import Flask + + prod_endpoint = "https://prod.example/api/v1/svc" + app = Flask(__name__) + app.config["NUBES_API_ENDPOINT"] = auth.STANDS[1] + app.config["NUBES_API_PROD_ENDPOINT"] = prod_endpoint + monkeypatch.setattr(auth, "get_mode", lambda: "cloud") + monkeypatch.setattr(auth, "get_token", lambda: "prod-token") + monkeypatch.setattr(auth, "_cached_detect", lambda token: prod_endpoint) + monkeypatch.setattr(auth, "current_app", app) + + assert auth.get_stand() == "prod"