feat: 17 API-эндпоинтов — полный эмулятор Nubes API (v0.1.0)
This commit is contained in:
+368
-17
@@ -2,21 +2,47 @@
|
|||||||
polygon v0.1.0 — Mock Nubes API.
|
polygon v0.1.0 — Mock Nubes API.
|
||||||
Эмулятор REST API облачной платформы Nubes для интеграционных тестов.
|
Эмулятор REST API облачной платформы Nubes для интеграционных тестов.
|
||||||
|
|
||||||
Эндпоинты с префиксом /api/v1/svc притворяются реальным Nubes API.
|
Все эндпоинты с префиксом /api/v1/svc притворяются реальным Nubes API.
|
||||||
Состояние инстансов/операций — в памяти (MockState).
|
Состояние инстансов/операций — в памяти (MockState).
|
||||||
|
Конфиги сервисов — из services/*.yaml (сгенерированы from_stands.py).
|
||||||
|
|
||||||
Деплой: Nubes pythonk8s (managed service) с gunicorn:
|
Деплой: Nubes pythonk8s (managed service) с gunicorn:
|
||||||
gunicorn app:app --workers 2 --bind 0.0.0.0:8000
|
gunicorn app:app --workers 2 --bind 0.0.0.0:8000
|
||||||
|
|
||||||
|
Переменные окружения:
|
||||||
|
MOCK_OP_DELAY — задержка операции в секундах (по умолчанию 0.1)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from flask import Flask, jsonify, request
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, make_response, request
|
||||||
|
|
||||||
|
import config_loader
|
||||||
|
import mock_state
|
||||||
|
import state_machine
|
||||||
|
|
||||||
# Версия полигона — независима от app-autotest
|
|
||||||
VERSION = "0.1.0"
|
VERSION = "0.1.0"
|
||||||
|
|
||||||
|
# Задержка операции (синхронный sleep в /run)
|
||||||
|
MOCK_OP_DELAY = float(os.getenv("MOCK_OP_DELAY", "0.1"))
|
||||||
|
|
||||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||||
|
|
||||||
|
# Загружаем сервисы при старте
|
||||||
|
_SERVICES, _OPS_INDEX = config_loader.load_services("services")
|
||||||
|
_STATE = mock_state.state
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
"""UTC-строка в ISO-формате с 'Z'."""
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Health / Root
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
@app.route("/health")
|
@app.route("/health")
|
||||||
def health():
|
def health():
|
||||||
@@ -26,41 +52,366 @@ def health():
|
|||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
"""Корневой эндпоинт — информация о сервисе (HTML)."""
|
"""Корневой эндпоинт — HTML с версией и статусом."""
|
||||||
|
svc_count = len(_SERVICES)
|
||||||
|
inst_count = len(_STATE.instances)
|
||||||
return f"""<!DOCTYPE html>
|
return f"""<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head><meta charset="utf-8"><title>polygon v{VERSION}</title>
|
<head><meta charset="utf-8"><title>polygon v{VERSION}</title>
|
||||||
<style>body{{font-family:system-ui;max-width:600px;margin:40px auto;padding:0 20px;background:#111;color:#eee}}
|
<style>body{{font-family:system-ui;max-width:600px;margin:40px auto;padding:0 20px;background:#111;color:#eee}}
|
||||||
h1{{color:#4af}} .ok{{color:#4a4}} .ver{{color:#888}}</style></head>
|
h1{{color:#4af}}.ok{{color:#4a4}}.ver{{color:#888}}code{{background:#333;padding:2px 6px;border-radius:4px}}</style></head>
|
||||||
<body>
|
<body>
|
||||||
<h1>polygon <span class="ver">v{VERSION}</span></h1>
|
<h1>polygon <span class="ver">v{VERSION}</span></h1>
|
||||||
<p class="ok">● running</p>
|
<p class="ok">● running</p>
|
||||||
<p>Mock Nubes API — эмулятор REST API облачной платформы.</p>
|
<p>Сервисов: {svc_count} | Инстансов: {inst_count} | delay: {MOCK_OP_DELAY}s</p>
|
||||||
<p>Эндпоинты: <code>/api/v1/svc/*</code></p>
|
<p>Эндпоинты: <code>/api/v1/svc/*</code></p>
|
||||||
<p>Сброс: <code>POST /api/v1/svc/_mock/reset</code></p>
|
|
||||||
</body></html>"""
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ===========================================================================
|
||||||
# Nubes API mock — префикс /api/v1/svc
|
# Services (GET /services, GET /services/{id})
|
||||||
# ---------------------------------------------------------------------------
|
# ===========================================================================
|
||||||
|
|
||||||
|
@app.route("/api/v1/svc/services", methods=["GET"])
|
||||||
|
def list_services():
|
||||||
|
"""GET /services — список всех сервисов."""
|
||||||
|
results = []
|
||||||
|
for svc_id, svc_def in _SERVICES.items():
|
||||||
|
results.append({
|
||||||
|
"svcId": svc_id,
|
||||||
|
"svc": svc_def.get("service_display_name", ""),
|
||||||
|
"svcShort": svc_def.get("service_short_name", svc_def.get("name", "")),
|
||||||
|
})
|
||||||
|
return jsonify({"results": results})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/svc/services/<int:svc_id>", methods=["GET"])
|
||||||
|
def get_service(svc_id):
|
||||||
|
"""GET /services/{id} — детали сервиса (список операций)."""
|
||||||
|
svc_def = _SERVICES.get(svc_id)
|
||||||
|
if not svc_def:
|
||||||
|
return jsonify({"error": "service not found"}), 404
|
||||||
|
|
||||||
|
ops = []
|
||||||
|
for op in svc_def.get("operations", []):
|
||||||
|
ops.append({
|
||||||
|
"svcOperationId": op["svcOperationId"],
|
||||||
|
"operation": op["operation"],
|
||||||
|
})
|
||||||
|
|
||||||
@app.route("/api/v1/svc/instances", methods=["GET"])
|
|
||||||
def list_instances():
|
|
||||||
"""GET /instances — пока пустой список."""
|
|
||||||
page_size = request.args.get("pageSize", 200, type=int)
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"results": [],
|
"svc": {
|
||||||
"pageSize": min(page_size, 200),
|
"svc": svc_def.get("service_display_name", ""),
|
||||||
|
"svcShort": svc_def.get("service_short_name", svc_def.get("name", "")),
|
||||||
|
"operations": ops,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Instances (GET list, GET detail, POST create)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
@app.route("/api/v1/svc/instances", methods=["GET"])
|
||||||
|
def list_instances():
|
||||||
|
"""GET /instances — список с пагинацией."""
|
||||||
|
page_size = request.args.get("pageSize", 200, type=int)
|
||||||
|
page = request.args.get("page", 1, type=int)
|
||||||
|
result = _STATE.list_instances(page_size=page_size, page=page)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/svc/instances/<uid>", methods=["GET"])
|
||||||
|
def get_instance(uid):
|
||||||
|
"""GET /instances/{uid} — полные данные инстанса."""
|
||||||
|
inst = _STATE.get_instance(uid)
|
||||||
|
if not inst:
|
||||||
|
return jsonify({"error": "instance not found"}), 404
|
||||||
|
|
||||||
|
return jsonify({"instance": dict(inst)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/svc/instances", methods=["POST"])
|
||||||
|
def create_instance():
|
||||||
|
"""POST /instances — создать shell инстанса.
|
||||||
|
|
||||||
|
Ожидает JSON: {serviceId, displayName}
|
||||||
|
Возвращает 201 + Location: ./{instanceUid} + {"instanceUid": uid}
|
||||||
|
"""
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
service_id = body.get("serviceId")
|
||||||
|
display_name = body.get("displayName", "unnamed")
|
||||||
|
|
||||||
|
if service_id is None:
|
||||||
|
return jsonify({"error": "serviceId is required"}), 400
|
||||||
|
|
||||||
|
svc_def = _SERVICES.get(service_id)
|
||||||
|
if not svc_def:
|
||||||
|
return jsonify({"error": f"service {service_id} not found"}), 404
|
||||||
|
|
||||||
|
uid = _STATE.create_instance(service_id, display_name, svc_def)
|
||||||
|
|
||||||
|
resp = make_response(jsonify({"instanceUid": uid}), 201)
|
||||||
|
resp.headers["Location"] = f"./{uid}"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Instance Operations (create, get, params, validate, run)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
# ВАЖНО: default/<int:op_id> ДОЛЖЕН быть выше <uid> — иначе Flask
|
||||||
|
# распарсит "default" как uid и уйдёт в get_operation с op_id="default".
|
||||||
|
|
||||||
|
@app.route("/api/v1/svc/instanceOperations/default/<int:op_id>", methods=["GET"])
|
||||||
|
def get_operation_default(op_id):
|
||||||
|
"""GET /instanceOperations/default/{id} — шаблон операции (cfsParams).
|
||||||
|
|
||||||
|
Это ключевой эндпоинт для app-autotest: он получает отсюда
|
||||||
|
список параметров с dataDescriptor, valueList, isModifiable.
|
||||||
|
"""
|
||||||
|
svc_def = _OPS_INDEX.get(op_id)
|
||||||
|
if not svc_def:
|
||||||
|
return jsonify({"error": "operation not found"}), 404
|
||||||
|
|
||||||
|
# Найти операцию по svcOperationId
|
||||||
|
target_op = None
|
||||||
|
for op in svc_def.get("operations", []):
|
||||||
|
if op["svcOperationId"] == op_id:
|
||||||
|
target_op = op
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target_op:
|
||||||
|
return jsonify({"error": "operation not found"}), 404
|
||||||
|
|
||||||
|
# Собрать cfsParams для этой операции
|
||||||
|
cfs_params_by_op = svc_def.get("cfsParamsByOp", {})
|
||||||
|
cfs_params = {p["svcOperationCfsParamId"]: p for p in svc_def.get("cfsParams", [])}
|
||||||
|
param_ids = cfs_params_by_op.get(op_id, [])
|
||||||
|
|
||||||
|
cfs_list = []
|
||||||
|
for pid in param_ids:
|
||||||
|
p = cfs_params.get(pid)
|
||||||
|
if p:
|
||||||
|
cfs_list.append(dict(p))
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"svcOperation": {
|
||||||
|
"svcOperationId": op_id,
|
||||||
|
"operation": target_op.get("operation", ""),
|
||||||
|
"cfsParams": cfs_list,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/svc/instanceOperations", methods=["POST"])
|
||||||
|
def create_operation():
|
||||||
|
"""POST /instanceOperations — создать операцию.
|
||||||
|
|
||||||
|
Ожидает JSON: {instanceUid, svcOperationId?, operation}
|
||||||
|
Если operation="create" — svcOperationId может отсутствовать,
|
||||||
|
polygon находит его сам из service_def.
|
||||||
|
|
||||||
|
Возвращает 201 + Location: ./{opUid} + {"instanceOperationUid": opUid}
|
||||||
|
"""
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
instance_uid = body.get("instanceUid")
|
||||||
|
operation = body.get("operation", "")
|
||||||
|
svc_op_id = body.get("svcOperationId")
|
||||||
|
|
||||||
|
if not instance_uid:
|
||||||
|
return jsonify({"error": "instanceUid is required"}), 400
|
||||||
|
|
||||||
|
inst = _STATE.get_instance(instance_uid)
|
||||||
|
if not inst:
|
||||||
|
return jsonify({"error": "instance not found"}), 404
|
||||||
|
|
||||||
|
# Если svcOperationId не передан — найти по operation name
|
||||||
|
if svc_op_id is None:
|
||||||
|
svc_id = inst.get("serviceId")
|
||||||
|
svc_def = _SERVICES.get(svc_id, {})
|
||||||
|
for op in svc_def.get("operations", []):
|
||||||
|
if op["operation"] == operation:
|
||||||
|
svc_op_id = op["svcOperationId"]
|
||||||
|
break
|
||||||
|
|
||||||
|
if svc_op_id is None:
|
||||||
|
return jsonify({"error": f"operation '{operation}' not found for service"}), 404
|
||||||
|
|
||||||
|
svc_def = _OPS_INDEX.get(svc_op_id, {})
|
||||||
|
kind = "instance"
|
||||||
|
action = operation
|
||||||
|
for op in svc_def.get("operations", []):
|
||||||
|
if op["svcOperationId"] == svc_op_id:
|
||||||
|
kind = op.get("kind", "instance")
|
||||||
|
action = op.get("action", operation)
|
||||||
|
break
|
||||||
|
|
||||||
|
op_uid = _STATE.create_operation(instance_uid, svc_op_id, operation, kind, action)
|
||||||
|
|
||||||
|
resp = make_response(jsonify({"instanceOperationUid": op_uid}), 201)
|
||||||
|
resp.headers["Location"] = f"./{op_uid}"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/svc/instanceOperations/<uid>", methods=["GET"])
|
||||||
|
def get_operation(uid):
|
||||||
|
"""GET /instanceOperations/{uid} — статус операции.
|
||||||
|
|
||||||
|
Query params: ?fields=cfsParams,... — app-autotest фильтрует поля.
|
||||||
|
Мок отдаёт всё, фильтрация на клиенте.
|
||||||
|
"""
|
||||||
|
op = _STATE.get_operation(uid)
|
||||||
|
if not op:
|
||||||
|
return jsonify({"error": "operation not found"}), 404
|
||||||
|
|
||||||
|
result = dict(op)
|
||||||
|
|
||||||
|
# Если запрошены cfsParams — добавить их
|
||||||
|
fields = request.args.get("fields", "")
|
||||||
|
if "cfsParams" in fields:
|
||||||
|
svc_op_id = op.get("svcOperationId")
|
||||||
|
svc_def = _OPS_INDEX.get(svc_op_id, {})
|
||||||
|
cfs_params_by_op = svc_def.get("cfsParamsByOp", {})
|
||||||
|
cfs_params = {p["svcOperationCfsParamId"]: p for p in svc_def.get("cfsParams", [])}
|
||||||
|
param_ids = cfs_params_by_op.get(svc_op_id, [])
|
||||||
|
op_params = _STATE.get_params(uid)
|
||||||
|
|
||||||
|
cfs_list = []
|
||||||
|
for pid in param_ids:
|
||||||
|
p = cfs_params.get(pid)
|
||||||
|
if p:
|
||||||
|
entry = dict(p)
|
||||||
|
entry["paramValue"] = op_params.get(pid, "")
|
||||||
|
cfs_list.append(entry)
|
||||||
|
|
||||||
|
result["cfsParams"] = cfs_list
|
||||||
|
|
||||||
|
return jsonify({"instanceOperation": result})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/svc/instanceOperationCfsParams", methods=["POST"])
|
||||||
|
def set_operation_param():
|
||||||
|
"""POST /instanceOperationCfsParams — установить значение параметра.
|
||||||
|
|
||||||
|
Ожидает JSON: {instanceOperationUid, svcOperationCfsParamId, paramValue}
|
||||||
|
"""
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
op_uid = body.get("instanceOperationUid")
|
||||||
|
param_id = body.get("svcOperationCfsParamId")
|
||||||
|
value = body.get("paramValue", "")
|
||||||
|
|
||||||
|
if not op_uid or param_id is None:
|
||||||
|
return jsonify({"error": "instanceOperationUid and svcOperationCfsParamId required"}), 400
|
||||||
|
|
||||||
|
if not _STATE.get_operation(op_uid):
|
||||||
|
return jsonify({"error": "operation not found"}), 404
|
||||||
|
|
||||||
|
_STATE.set_param(op_uid, param_id, value)
|
||||||
|
return jsonify({})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/svc/instanceOperations/<uid>/validate-cfs", methods=["GET"])
|
||||||
|
def validate_cfs(uid):
|
||||||
|
"""GET /instanceOperations/{uid}/validate-cfs — всегда OK.
|
||||||
|
|
||||||
|
⛔ КРИТИЧНО: возвращает пустое тело (200), НЕ jsonify.
|
||||||
|
app-autotest ловит JSONDecodeError и считает это успехом.
|
||||||
|
"""
|
||||||
|
if not _STATE.get_operation(uid):
|
||||||
|
return jsonify({"error": "operation not found"}), 404
|
||||||
|
|
||||||
|
return "", 200
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/svc/instanceOperations/<uid>/run", methods=["POST"])
|
||||||
|
def run_operation(uid):
|
||||||
|
"""POST /instanceOperations/{uid}/run — выполнить операцию.
|
||||||
|
|
||||||
|
Синхронный: sleep(MOCK_OP_DELAY) → apply_effect → dtFinish = now.
|
||||||
|
"""
|
||||||
|
op = _STATE.get_operation(uid)
|
||||||
|
if not op:
|
||||||
|
return jsonify({"error": "operation not found"}), 404
|
||||||
|
|
||||||
|
op["dtStart"] = _now()
|
||||||
|
|
||||||
|
# Синхронная задержка
|
||||||
|
if MOCK_OP_DELAY > 0:
|
||||||
|
time.sleep(MOCK_OP_DELAY)
|
||||||
|
|
||||||
|
# Применить эффект
|
||||||
|
state_machine.apply_effect(uid, _STATE, _SERVICES)
|
||||||
|
|
||||||
|
op["dtFinish"] = _now()
|
||||||
|
op["isSuccessful"] = True
|
||||||
|
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Mock control (reset, state, services, delay)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
@app.route("/api/v1/svc/_mock/reset", methods=["POST"])
|
@app.route("/api/v1/svc/_mock/reset", methods=["POST"])
|
||||||
def mock_reset():
|
def mock_reset():
|
||||||
"""Сброс состояния (пока ничего нет)."""
|
"""POST /_mock/reset — полный сброс состояния."""
|
||||||
|
_STATE.reset()
|
||||||
return jsonify({"reset": "ok"})
|
return jsonify({"reset": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/svc/_mock/state", methods=["GET"])
|
||||||
|
def mock_state_view():
|
||||||
|
"""GET /_mock/state — отладочный дамп текущего состояния."""
|
||||||
|
return jsonify({
|
||||||
|
"instances": {
|
||||||
|
uid: {
|
||||||
|
"instanceUid": inst["instanceUid"],
|
||||||
|
"displayName": inst["displayName"],
|
||||||
|
"status": inst["status"],
|
||||||
|
"serviceId": inst["serviceId"],
|
||||||
|
}
|
||||||
|
for uid, inst in _STATE.instances.items()
|
||||||
|
},
|
||||||
|
"operations": {
|
||||||
|
uid: {
|
||||||
|
"instanceOperationUid": op["instanceOperationUid"],
|
||||||
|
"instanceUid": op["instanceUid"],
|
||||||
|
"operation": op["operation"],
|
||||||
|
"dtFinish": op["dtFinish"],
|
||||||
|
"isSuccessful": op["isSuccessful"],
|
||||||
|
}
|
||||||
|
for uid, op in _STATE.operations.items()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/svc/_mock/services", methods=["GET"])
|
||||||
|
def mock_services():
|
||||||
|
"""GET /_mock/services — список загруженных сервисов."""
|
||||||
|
result = {}
|
||||||
|
for svc_id, svc_def in _SERVICES.items():
|
||||||
|
result[str(svc_id)] = {
|
||||||
|
"name": svc_def.get("name", ""),
|
||||||
|
"displayName": svc_def.get("service_display_name", ""),
|
||||||
|
"operations": len(svc_def.get("operations", [])),
|
||||||
|
"params": len(svc_def.get("cfsParams", [])),
|
||||||
|
}
|
||||||
|
return jsonify({"count": len(result), "services": result})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/svc/_mock/delay/<float:seconds>", methods=["POST"])
|
||||||
|
def mock_set_delay(seconds):
|
||||||
|
"""POST /_mock/delay/{s} — изменить MOCK_OP_DELAY на лету."""
|
||||||
|
global MOCK_OP_DELAY
|
||||||
|
MOCK_OP_DELAY = seconds
|
||||||
|
return jsonify({"delay": MOCK_OP_DELAY})
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Entry point
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
# Локальный запуск (без gunicorn) — только для разработки
|
# Локальный запуск (без gunicorn) — только для разработки
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(debug=True, host="0.0.0.0", port=5000)
|
app.run(debug=True, host="0.0.0.0", port=5000)
|
||||||
|
|||||||
Reference in New Issue
Block a user