Files
polygon/site/app.py
T
2026-07-31 22:01:30 +04:00

424 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
polygon v0.2.0 — Mock Nubes API.
Эмулятор REST API облачной платформы Nubes для интеграционных тестов.
Все эндпоинты с префиксом /api/v1/svc притворяются реальным Nubes API.
Состояние инстансов/операций — в памяти (MockState).
Конфиги сервисов — из services/*.yaml (сгенерированы from_stands.py).
Деплой: Nubes pythonk8s (managed service) с gunicorn:
gunicorn app:app --workers 1 --bind 0.0.0.0:8000
⛔ РОВНО 1 ВОРКЕР — состояние в памяти, не shared между процессами.
Переменные окружения:
MOCK_OP_DELAY — задержка операции в секундах (по умолчанию 0.1)
WEB_CONCURRENCY — число gunicorn-воркеров (принудительно 1)
"""
import os
import time
from datetime import datetime, timezone
from flask import Flask, jsonify, make_response, request
# ⛔ Принудительно 1 воркер — состояние в памяти, иначе инстансы теряются.
# setdefault не перезапишет если Nubes/Procfile уже выставил WEB_CONCURRENCY=1.
os.environ.setdefault("WEB_CONCURRENCY", "1")
import config_loader
import mock_state
import state_machine
VERSION = "0.2.1"
# Задержка операции (синхронный sleep в /run)
MOCK_OP_DELAY = float(os.getenv("MOCK_OP_DELAY", "0.1"))
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")
def health():
"""Healthcheck для Nubes — всегда возвращает 200 OK."""
return "OK"
@app.route("/")
def index():
"""Корневой эндпоинт — HTML с версией и статусом."""
svc_count = len(_SERVICES)
inst_count = len(_STATE.instances)
return f"""<!DOCTYPE html>
<html lang="ru">
<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}}
h1{{color:#4af}}.ok{{color:#4a4}}.ver{{color:#888}}code{{background:#333;padding:2px 6px;border-radius:4px}}</style></head>
<body>
<h1>polygon <span class="ver">v{VERSION}</span></h1>
<p class="ok">● running</p>
<p>Сервисов: {svc_count} &nbsp;|&nbsp; Инстансов: {inst_count} &nbsp;|&nbsp; delay: {MOCK_OP_DELAY}s</p>
<p>Эндпоинты: <code>/api/v1/svc/*</code></p>
</body></html>"""
# ===========================================================================
# 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"],
})
return jsonify({
"svc": {
"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"])
def mock_reset():
"""POST /_mock/reset — полный сброс состояния."""
_STATE.reset()
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) — только для разработки
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=5000)