refactor: decouple — blueprint'ы + CSS/HTML разделение (по шаблону app-autotest)
This commit is contained in:
+25
-393
@@ -1,10 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
polygon v0.2.0 — Mock Nubes API.
|
polygon v0.2.1 — Mock Nubes API.
|
||||||
Эмулятор REST API облачной платформы Nubes для интеграционных тестов.
|
Эмулятор REST API облачной платформы Nubes для интеграционных тестов.
|
||||||
|
|
||||||
Все эндпоинты с префиксом /api/v1/svc притворяются реальным Nubes API.
|
Blueprint'ы (каждый — отдельный файл в routes/):
|
||||||
Состояние инстансов/операций — в памяти (MockState).
|
- root_bp → /health, / (HTML-страница)
|
||||||
Конфиги сервисов — из services/*.yaml (сгенерированы from_stands.py).
|
- services_bp → /api/v1/svc/services, /services/<id>
|
||||||
|
- instances_bp → /api/v1/svc/instances (GET list, GET detail, POST create)
|
||||||
|
- operations_bp → /api/v1/svc/instanceOperations/* (create, default, status, params, validate)
|
||||||
|
- run_bp → /api/v1/svc/instanceOperations/<uid>/run
|
||||||
|
- mock_bp → /api/v1/svc/_mock/* (reset, state, services, delay)
|
||||||
|
|
||||||
Деплой: Nubes pythonk8s (managed service) с gunicorn:
|
Деплой: Nubes pythonk8s (managed service) с gunicorn:
|
||||||
gunicorn app:app --workers 1 --bind 0.0.0.0:8000
|
gunicorn app:app --workers 1 --bind 0.0.0.0:8000
|
||||||
@@ -16,408 +20,36 @@ polygon v0.2.0 — Mock Nubes API.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from flask import Flask, jsonify, make_response, request
|
from flask import Flask
|
||||||
|
|
||||||
# ⛔ Принудительно 1 воркер — состояние в памяти, иначе инстансы теряются.
|
# ⛔ Принудительно 1 воркер — состояние в памяти, иначе инстансы теряются.
|
||||||
# setdefault не перезапишет если Nubes/Procfile уже выставил WEB_CONCURRENCY=1.
|
# setdefault не перезапишет если Nubes/Procfile уже выставил WEB_CONCURRENCY=1.
|
||||||
os.environ.setdefault("WEB_CONCURRENCY", "1")
|
os.environ.setdefault("WEB_CONCURRENCY", "1")
|
||||||
|
|
||||||
import config_loader
|
# Импорт blueprint'ов — каждый отвечает за свою группу маршрутов
|
||||||
import mock_state
|
from routes.root import bp as root_bp
|
||||||
import state_machine
|
from routes.services_routes import bp as services_bp
|
||||||
|
from routes.instances_routes import bp as instances_bp
|
||||||
|
from routes.operations_routes import bp as operations_bp
|
||||||
|
from routes.run import bp as run_bp
|
||||||
|
from routes.mock_routes import bp as mock_bp
|
||||||
|
|
||||||
VERSION = "0.2.1"
|
VERSION = "0.2.1"
|
||||||
|
|
||||||
# Задержка операции (синхронный sleep в /run)
|
# Flask-приложение с Jinja2-шаблонами и статикой
|
||||||
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")
|
||||||
|
|
||||||
# Загружаем сервисы при старте
|
# Регистрируем blueprint'ы.
|
||||||
_SERVICES, _OPS_INDEX = config_loader.load_services("services")
|
# Префиксы уже заданы в url_prefix каждого blueprint'а (кроме root — он без префикса).
|
||||||
_STATE = mock_state.state
|
app.register_blueprint(root_bp) # /health, /
|
||||||
|
app.register_blueprint(services_bp) # /api/v1/svc/services
|
||||||
|
app.register_blueprint(instances_bp) # /api/v1/svc/instances
|
||||||
|
app.register_blueprint(operations_bp) # /api/v1/svc/instanceOperations + /instanceOperationCfsParams
|
||||||
|
app.register_blueprint(run_bp) # /api/v1/svc/instanceOperations/<uid>/run
|
||||||
|
app.register_blueprint(mock_bp) # /api/v1/svc/_mock/*
|
||||||
|
|
||||||
|
|
||||||
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} | Инстансов: {inst_count} | 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) — только для разработки
|
# Локальный запуск (без 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)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
config/loader.py — загрузка сервисов и глобальные конфигурационные переменные.
|
||||||
|
|
||||||
|
Этот модуль загружает все YAML-конфиги сервисов при первом импорте и хранит их
|
||||||
|
как модульные переменные. Все routes/*.py импортируют SERVICES, OPS_INDEX, DELAY
|
||||||
|
отсюда — без дублирования загрузки и без циклических импортов.
|
||||||
|
|
||||||
|
Переменные модуля (вычисляются ОДИН раз при старте):
|
||||||
|
SERVICES — {service_id: service_def} все сервисы
|
||||||
|
OPS_INDEX — {svcOperationId: service_def} поиск сервиса по ID операции
|
||||||
|
DELAY — float, задержка операции из MOCK_OP_DELAY (по умолчанию 0.1)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
# Константы для загрузки YAML
|
||||||
|
_YAML_EXTS = (".yaml", ".yml")
|
||||||
|
_DEFAULT_SERVICES_DIR = "services"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_all(services_dir=_DEFAULT_SERVICES_DIR):
|
||||||
|
"""Загружает все YAML-файлы из директории сервисов.
|
||||||
|
|
||||||
|
Возвращает кортеж (services, ops_index):
|
||||||
|
services — {service_id: {name, operations, cfsParams, stateParams, stateOut, ...}}
|
||||||
|
ops_index — {svcOperationId: service_def} для быстрого поиска «чей это opId»
|
||||||
|
"""
|
||||||
|
if not os.path.isdir(services_dir):
|
||||||
|
raise FileNotFoundError(f"Services directory not found: {services_dir}")
|
||||||
|
|
||||||
|
services = {}
|
||||||
|
ops_index = {}
|
||||||
|
|
||||||
|
# Итерируем ВСЕ .yaml/.yml — без хардкода количества сервисов
|
||||||
|
for fname in sorted(os.listdir(services_dir)):
|
||||||
|
if not fname.lower().endswith(_YAML_EXTS):
|
||||||
|
continue
|
||||||
|
|
||||||
|
path = os.path.join(services_dir, fname)
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
cfg = yaml.safe_load(f)
|
||||||
|
|
||||||
|
if not cfg:
|
||||||
|
continue
|
||||||
|
|
||||||
|
svc_id = cfg["service_id"]
|
||||||
|
services[svc_id] = cfg
|
||||||
|
|
||||||
|
# Индекс: svcOperationId → service_def (нужен для поиска «чей это opId»)
|
||||||
|
for op in cfg.get("operations", []):
|
||||||
|
ops_index[op["svcOperationId"]] = cfg
|
||||||
|
|
||||||
|
return services, ops_index
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Модульные переменные — инициализируются ОДИН раз при первом импорте.
|
||||||
|
# Все routes/*.py импортируют SERVICES, OPS_INDEX, DELAY отсюда.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Загружаем конфиги сервисов (37+ YAML из services/)
|
||||||
|
SERVICES, OPS_INDEX = _load_all()
|
||||||
|
|
||||||
|
# Задержка операции в секундах (для синхронного sleep в /run).
|
||||||
|
# Меняется на лету через POST /_mock/delay/<s> — см. mock_routes.py.
|
||||||
|
DELAY = float(os.getenv("MOCK_OP_DELAY", "0.1"))
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
"""
|
|
||||||
config_loader.py — загрузка сгенерированных YAML-конфигов сервисов.
|
|
||||||
|
|
||||||
Читает все .yaml из services/, строит два индекса:
|
|
||||||
- services: {service_id: service_def}
|
|
||||||
- ops_index: {svcOperationId: service_def} (для поиска по opId)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
YAML_EXTS = (".yaml", ".yml")
|
|
||||||
|
|
||||||
|
|
||||||
def load_services(services_dir="services"):
|
|
||||||
"""Загружает все сервисы из директории YAML-конфигов.
|
|
||||||
|
|
||||||
Возвращает (services, ops_index):
|
|
||||||
services — dict[service_id] = service_def
|
|
||||||
ops_index — dict[svcOperationId] = service_def
|
|
||||||
|
|
||||||
service_def содержит:
|
|
||||||
name, service_id, service_display_name, service_short_name,
|
|
||||||
lifecycle, operations, cfsParams, cfsParamsByOp,
|
|
||||||
stateParams, stateOut
|
|
||||||
"""
|
|
||||||
if not os.path.isdir(services_dir):
|
|
||||||
raise FileNotFoundError(f"Services directory not found: {services_dir}")
|
|
||||||
|
|
||||||
services = {}
|
|
||||||
ops_index = {}
|
|
||||||
|
|
||||||
for fname in sorted(os.listdir(services_dir)):
|
|
||||||
if not fname.lower().endswith(YAML_EXTS):
|
|
||||||
continue
|
|
||||||
|
|
||||||
path = os.path.join(services_dir, fname)
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
cfg = yaml.safe_load(f)
|
|
||||||
|
|
||||||
if not cfg:
|
|
||||||
continue
|
|
||||||
|
|
||||||
svc_id = cfg["service_id"]
|
|
||||||
services[svc_id] = cfg
|
|
||||||
|
|
||||||
# Индекс по svcOperationId — для поиска «какому сервису принадлежит операция»
|
|
||||||
for op in cfg.get("operations", []):
|
|
||||||
ops_index[op["svcOperationId"]] = cfg
|
|
||||||
|
|
||||||
return services, ops_index
|
|
||||||
+1
-7
@@ -37,13 +37,7 @@ def _ue(s):
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
def _pluralize(word):
|
from utils.pluralize import pluralize as _pluralize
|
||||||
"""Простейшая плюрализация: user→users, database→databases."""
|
|
||||||
if word.endswith("s"):
|
|
||||||
return word
|
|
||||||
if word.endswith("y") and word[-2] not in "aeiou":
|
|
||||||
return word[:-1] + "ies"
|
|
||||||
return word + "s"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+1
-5
@@ -10,12 +10,8 @@ mock_state.py — состояние мок-полигона в памяти.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
|
from utils.now import now as _now
|
||||||
def _now():
|
|
||||||
"""UTC-строка в ISO-формате с 'Z' (как в реальном API)."""
|
|
||||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
||||||
|
|
||||||
|
|
||||||
class MockState:
|
class MockState:
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""
|
||||||
|
routes/instances_routes.py — эндпоинты инстансов.
|
||||||
|
|
||||||
|
Blueprint "instances" с префиксом /api/v1/svc/instances.
|
||||||
|
GET /instances — список с пагинацией
|
||||||
|
GET /instances/<uid> — полные данные инстанса (state.params + state.out)
|
||||||
|
POST /instances — создать shell инстанса → 201 + Location: ./{uid}
|
||||||
|
|
||||||
|
КРИТИЧНО: POST /instances ОБЯЗАН возвращать заголовок Location.
|
||||||
|
HttpClient.post() в app-autotest достаёт instanceUid ИМЕННО из Location.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, make_response, request
|
||||||
|
|
||||||
|
import mock_state
|
||||||
|
from config.loader import SERVICES
|
||||||
|
|
||||||
|
bp = Blueprint("instances", __name__, url_prefix="/api/v1/svc/instances")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("", methods=["GET"])
|
||||||
|
@bp.route("/", methods=["GET"])
|
||||||
|
def list_instances():
|
||||||
|
"""GET /api/v1/svc/instances — список инстансов с пагинацией.
|
||||||
|
|
||||||
|
Query-параметры:
|
||||||
|
pageSize — размер страницы (по умолчанию 200, максимум 200)
|
||||||
|
page — номер страницы (по умолчанию 1)
|
||||||
|
|
||||||
|
Возвращает {results, pageSize, page, total}.
|
||||||
|
Пагинация: стоп по len(batch) < pageSize (как в реальном API).
|
||||||
|
"""
|
||||||
|
page_size = request.args.get("pageSize", 200, type=int)
|
||||||
|
page = request.args.get("page", 1, type=int)
|
||||||
|
result = mock_state.state.list_instances(page_size=page_size, page=page)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/<uid>", methods=["GET"])
|
||||||
|
def get_instance(uid):
|
||||||
|
"""GET /api/v1/svc/instances/{uid} — полные данные инстанса.
|
||||||
|
|
||||||
|
Возвращает {instance: {instanceUid, serviceId, displayName, status,
|
||||||
|
explainedStatus, svc, dtCreate, state: {params: {...}, out: {...}}}}.
|
||||||
|
|
||||||
|
Если инстанс не найден — 404 с JSON-ошибкой.
|
||||||
|
"""
|
||||||
|
inst = mock_state.state.get_instance(uid)
|
||||||
|
if not inst:
|
||||||
|
return jsonify({"error": "instance not found"}), 404
|
||||||
|
|
||||||
|
return jsonify({"instance": dict(inst)})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("", methods=["POST"])
|
||||||
|
@bp.route("/", methods=["POST"])
|
||||||
|
def create_instance():
|
||||||
|
"""POST /api/v1/svc/instances — создать shell инстанса.
|
||||||
|
|
||||||
|
Ожидает JSON: {serviceId: int, displayName: str?}
|
||||||
|
|
||||||
|
⛔ КРИТИЧНО: возвращает 201 + заголовок Location: ./{instanceUid}.
|
||||||
|
HttpClient.post() в app-autotest парсит Location чтобы получить UUID:
|
||||||
|
- Берёт последний сегмент после /
|
||||||
|
- Проверяет len(сегмент) >= 32 (длина UUID без дефисов)
|
||||||
|
|
||||||
|
Также возвращает {"instanceUid": uid} в теле — двойная защита
|
||||||
|
на случай если заголовок не дойдёт.
|
||||||
|
"""
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
service_id = body.get("serviceId")
|
||||||
|
display_name = body.get("displayName", "unnamed")
|
||||||
|
|
||||||
|
# Валидация: serviceId обязателен
|
||||||
|
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
|
||||||
|
|
||||||
|
# Создать shell инстанса (статус "creating")
|
||||||
|
uid = mock_state.state.create_instance(service_id, display_name, svc_def)
|
||||||
|
|
||||||
|
# ⛔ Location ОБЯЗАТЕЛЕН — без него executor не получит instanceUid
|
||||||
|
resp = make_response(jsonify({"instanceUid": uid}), 201)
|
||||||
|
resp.headers["Location"] = f"./{uid}"
|
||||||
|
return resp
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
routes/mock_routes.py — служебные эндпоинты /_mock/*.
|
||||||
|
|
||||||
|
Blueprint "mock" с префиксом /api/v1/svc/_mock.
|
||||||
|
POST /reset — полный сброс состояния (для тестов)
|
||||||
|
GET /state — отладочный дамп instances + operations
|
||||||
|
GET /services — отладочный список загруженных сервисов
|
||||||
|
POST /delay/<s> — изменить MOCK_OP_DELAY на лету (не перезапуская сервер)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify
|
||||||
|
|
||||||
|
import mock_state
|
||||||
|
from config.loader import SERVICES, DELAY as _DELAY
|
||||||
|
import config.loader as _cfg # для модификации модульной переменной DELAY
|
||||||
|
|
||||||
|
bp = Blueprint("mock", __name__, url_prefix="/api/v1/svc/_mock")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/reset", methods=["POST"])
|
||||||
|
def mock_reset():
|
||||||
|
"""POST /api/v1/svc/_mock/reset — полный сброс состояния.
|
||||||
|
|
||||||
|
Удаляет ВСЕ инстансы, операции и параметры из MockState.
|
||||||
|
Используется в тестах (autouse fixture) для изоляции тестов друг от друга.
|
||||||
|
"""
|
||||||
|
mock_state.state.reset()
|
||||||
|
return jsonify({"reset": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/state", methods=["GET"])
|
||||||
|
def mock_state_view():
|
||||||
|
"""GET /api/v1/svc/_mock/state — отладочный дамп состояния.
|
||||||
|
|
||||||
|
Возвращает {instances: {uid: {instanceUid, displayName, status, serviceId}},
|
||||||
|
operations: {uid: {instanceOperationUid, instanceUid, operation,
|
||||||
|
dtFinish, isSuccessful}}}.
|
||||||
|
|
||||||
|
Полезно для отладки тестов: «какие инстансы сейчас живы?», «завершилась ли
|
||||||
|
операция?». НЕ для production-использования.
|
||||||
|
"""
|
||||||
|
st = mock_state.state
|
||||||
|
return jsonify({
|
||||||
|
"instances": {
|
||||||
|
uid: {
|
||||||
|
"instanceUid": inst["instanceUid"],
|
||||||
|
"displayName": inst["displayName"],
|
||||||
|
"status": inst["status"],
|
||||||
|
"serviceId": inst["serviceId"],
|
||||||
|
}
|
||||||
|
for uid, inst in st.instances.items()
|
||||||
|
},
|
||||||
|
"operations": {
|
||||||
|
uid: {
|
||||||
|
"instanceOperationUid": op["instanceOperationUid"],
|
||||||
|
"instanceUid": op["instanceUid"],
|
||||||
|
"operation": op["operation"],
|
||||||
|
"dtFinish": op["dtFinish"],
|
||||||
|
"isSuccessful": op["isSuccessful"],
|
||||||
|
}
|
||||||
|
for uid, op in st.operations.items()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/services", methods=["GET"])
|
||||||
|
def mock_services():
|
||||||
|
"""GET /api/v1/svc/_mock/services — отладочный список сервисов.
|
||||||
|
|
||||||
|
Возвращает {count: N, services: {svcId: {name, displayName, operations, params}}}.
|
||||||
|
|
||||||
|
Полезно чтобы быстро проверить что все 37+ сервисов загрузились,
|
||||||
|
не дёргая реальный /api/v1/svc/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})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/delay/<float:seconds>", methods=["POST"])
|
||||||
|
def mock_set_delay(seconds):
|
||||||
|
"""POST /api/v1/svc/_mock/delay/{s} — изменить задержку операции.
|
||||||
|
|
||||||
|
Меняет config.loader.DELAY «на лету», без перезапуска сервера.
|
||||||
|
Полезно в тестах: установить delay=0 для мгновенных операций,
|
||||||
|
или delay=5 чтобы проверить таймауты поллинга.
|
||||||
|
|
||||||
|
ВАЖНО: меняет модульную переменную config.loader.DELAY, которую читает
|
||||||
|
routes/run.py при каждом вызове /run.
|
||||||
|
"""
|
||||||
|
_cfg.DELAY = seconds
|
||||||
|
return jsonify({"delay": _cfg.DELAY})
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""
|
||||||
|
routes/operations_routes.py — эндпоинты операций (instanceOperations).
|
||||||
|
|
||||||
|
Blueprint "operations" с префиксом /api/v1/svc.
|
||||||
|
GET /instanceOperations/default/<int:op_id> — шаблон операции (cfsParams)
|
||||||
|
POST /instanceOperations — создать операцию → 201 + Location
|
||||||
|
GET /instanceOperations/<uid> — статус операции (+cfsParams)
|
||||||
|
POST /instanceOperationCfsParams — установить параметр
|
||||||
|
GET /instanceOperations/<uid>/validate-cfs — пустое тело, 200
|
||||||
|
|
||||||
|
ВАЖНО: порядок маршрутов в蓝图 критичен.
|
||||||
|
/instanceOperations/default/<int:op_id> ДОЛЖЕН быть выше
|
||||||
|
/instanceOperations/<uid>
|
||||||
|
Иначе Flask распарсит "default" как uid и уйдёт не в тот обработчик.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, make_response, request
|
||||||
|
|
||||||
|
import mock_state
|
||||||
|
import state_machine
|
||||||
|
from config.loader import SERVICES, OPS_INDEX
|
||||||
|
|
||||||
|
bp = Blueprint("operations", __name__, url_prefix="/api/v1/svc")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Хелпер: сборка cfsParams-списка для операции
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_cfs_list(svc_def, op_id, op_params=None):
|
||||||
|
"""Собрать список cfsParams для заданной операции.
|
||||||
|
|
||||||
|
Используется в двух местах:
|
||||||
|
1. get_operation_default — шаблон БЕЗ paramValue
|
||||||
|
2. get_operation — статус операции С paramValue из op_params
|
||||||
|
|
||||||
|
Args:
|
||||||
|
svc_def — конфиг сервиса (из SERVICES или OPS_INDEX)
|
||||||
|
op_id — svcOperationId
|
||||||
|
op_params — {paramId: value} или None (если не нужны текущие значения)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[{svcOperationCfsParamId, svcOperationCfsParam, dataType, ...,
|
||||||
|
paramValue?}]
|
||||||
|
"""
|
||||||
|
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:
|
||||||
|
entry = dict(p)
|
||||||
|
# Если переданы op_params — добавить текущее значение параметра
|
||||||
|
if op_params is not None:
|
||||||
|
entry["paramValue"] = op_params.get(pid, "")
|
||||||
|
cfs_list.append(entry)
|
||||||
|
|
||||||
|
return cfs_list
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /instanceOperations/default/<int:op_id>
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@bp.route("/instanceOperations/default/<int:op_id>", methods=["GET"])
|
||||||
|
def get_operation_default(op_id):
|
||||||
|
"""GET /instanceOperations/default/{id} — шаблон операции.
|
||||||
|
|
||||||
|
КЛЮЧЕВОЙ эндпоинт для app-autotest. Именно отсюда executor берёт список
|
||||||
|
параметров (cfsParams) с:
|
||||||
|
- dataDescriptor (подполя map-fixed: cpu, memory, replicas, ...)
|
||||||
|
- valueList (допустимые значения для select/дропдаунов)
|
||||||
|
- isModifiable (какие параметры можно менять при modify)
|
||||||
|
- isRequired, defaultValue
|
||||||
|
|
||||||
|
Без этого эндпоинта app-autotest не сможет отрендерить форму параметров.
|
||||||
|
|
||||||
|
Если op_id не найден ни в одном сервисе — 404.
|
||||||
|
"""
|
||||||
|
# Ищем сервис по ID операции через OPS_INDEX (построен в config/loader.py)
|
||||||
|
svc_def = OPS_INDEX.get(op_id)
|
||||||
|
if not svc_def:
|
||||||
|
return jsonify({"error": "operation not found"}), 404
|
||||||
|
|
||||||
|
# Найти операцию в списке операций сервиса
|
||||||
|
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 без paramValue (шаблон)
|
||||||
|
cfs_list = _build_cfs_list(svc_def, op_id)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"svcOperation": {
|
||||||
|
"svcOperationId": op_id,
|
||||||
|
"operation": target_op.get("operation", ""),
|
||||||
|
"cfsParams": cfs_list,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /instanceOperations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@bp.route("/instanceOperations", methods=["POST"])
|
||||||
|
def create_operation():
|
||||||
|
"""POST /instanceOperations — создать операцию для инстанса.
|
||||||
|
|
||||||
|
Ожидает JSON: {instanceUid: str, operation: str, svcOperationId?: int}
|
||||||
|
|
||||||
|
Особый случай: когда operation="create", app-autotest НЕ передаёт
|
||||||
|
svcOperationId (потому что на этом этапе executor ещё не знает ID
|
||||||
|
create-операции). Polygon находит svcOperationId сам через service_def.
|
||||||
|
|
||||||
|
⛔ КРИТИЧНО: возвращает 201 + заголовок Location: ./{opUid}.
|
||||||
|
HttpClient.post() достаёт instanceOperationUid из Location.
|
||||||
|
|
||||||
|
Также заполняет kind и action операции из конфига сервиса — они нужны
|
||||||
|
state_machine.apply_effect() чтобы понять что делать при run.
|
||||||
|
"""
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
instance_uid = body.get("instanceUid")
|
||||||
|
operation = body.get("operation", "")
|
||||||
|
svc_op_id = body.get("svcOperationId")
|
||||||
|
|
||||||
|
# Валидация: instanceUid обязателен
|
||||||
|
if not instance_uid:
|
||||||
|
return jsonify({"error": "instanceUid is required"}), 400
|
||||||
|
|
||||||
|
# Проверка что инстанс существует
|
||||||
|
inst = mock_state.state.get_instance(instance_uid)
|
||||||
|
if not inst:
|
||||||
|
return jsonify({"error": "instance not found"}), 404
|
||||||
|
|
||||||
|
# Если svcOperationId не передан — найти по имени операции
|
||||||
|
# (например app-autotest при create не знает ID, передаёт только "create")
|
||||||
|
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
|
||||||
|
|
||||||
|
# Определить kind и action операции из конфига
|
||||||
|
# (один проход — поиск по svcOperationId, не по имени)
|
||||||
|
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
|
||||||
|
|
||||||
|
# Создать операцию в MockState (dtFinish=None — ещё не выполнена)
|
||||||
|
op_uid = mock_state.state.create_operation(instance_uid, svc_op_id, operation, kind, action)
|
||||||
|
|
||||||
|
# ⛔ Location ОБЯЗАТЕЛЕН — без него executor не получит opUid
|
||||||
|
resp = make_response(jsonify({"instanceOperationUid": op_uid}), 201)
|
||||||
|
resp.headers["Location"] = f"./{op_uid}"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /instanceOperations/<uid>
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@bp.route("/instanceOperations/<uid>", methods=["GET"])
|
||||||
|
def get_operation(uid):
|
||||||
|
"""GET /instanceOperations/{uid} — статус операции.
|
||||||
|
|
||||||
|
Query-параметры:
|
||||||
|
?fields=cfsParams,... — если содержит "cfsParams", добавляет в ответ
|
||||||
|
список параметров с ТЕКУЩИМИ paramValue.
|
||||||
|
|
||||||
|
Возвращает {instanceOperation: {instanceOperationUid, instanceUid,
|
||||||
|
svcOperationId, operation, kind, action, dtStart, dtFinish, isSuccessful,
|
||||||
|
errorLog, svc, stages, cfsParams?}}
|
||||||
|
|
||||||
|
app-autotest использует ?fields=dtFinish,isSuccessful для поллинга —
|
||||||
|
ждёт пока dtFinish != None.
|
||||||
|
"""
|
||||||
|
op = mock_state.state.get_operation(uid)
|
||||||
|
if not op:
|
||||||
|
return jsonify({"error": "operation not found"}), 404
|
||||||
|
|
||||||
|
result = dict(op)
|
||||||
|
|
||||||
|
# Если клиент запросил cfsParams — добавить их с текущими paramValue
|
||||||
|
fields = request.args.get("fields", "")
|
||||||
|
if "cfsParams" in fields:
|
||||||
|
svc_op_id = op.get("svcOperationId")
|
||||||
|
svc_def = OPS_INDEX.get(svc_op_id, {})
|
||||||
|
op_params = mock_state.state.get_params(uid)
|
||||||
|
result["cfsParams"] = _build_cfs_list(svc_def, svc_op_id, op_params)
|
||||||
|
|
||||||
|
return jsonify({"instanceOperation": result})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /instanceOperationCfsParams
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@bp.route("/instanceOperationCfsParams", methods=["POST"])
|
||||||
|
def set_operation_param():
|
||||||
|
"""POST /instanceOperationCfsParams — установить значение параметра операции.
|
||||||
|
|
||||||
|
Ожидает JSON: {instanceOperationUid, svcOperationCfsParamId, paramValue}
|
||||||
|
|
||||||
|
Вызывается N раз (по одному на каждый параметр) перед /run.
|
||||||
|
Значения сохраняются в mock_state.op_params[opUid][paramId] = value.
|
||||||
|
При apply_effect (modify) значения мержатся в state.params инстанса.
|
||||||
|
"""
|
||||||
|
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 mock_state.state.get_operation(op_uid):
|
||||||
|
return jsonify({"error": "operation not found"}), 404
|
||||||
|
|
||||||
|
# Сохранить параметр (param_id — int, value — str)
|
||||||
|
mock_state.state.set_param(op_uid, param_id, value)
|
||||||
|
return jsonify({})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /instanceOperations/<uid>/validate-cfs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@bp.route("/instanceOperations/<uid>/validate-cfs", methods=["GET"])
|
||||||
|
def validate_cfs(uid):
|
||||||
|
"""GET /instanceOperations/{uid}/validate-cfs — валидация параметров.
|
||||||
|
|
||||||
|
⛔ КРИТИЧНО: возвращает ПУСТОЕ тело с кодом 200.
|
||||||
|
НЕ использовать jsonify() — app-autotest делает requests.get().json()
|
||||||
|
и ловит JSONDecodeError, считая пустой ответ = успех.
|
||||||
|
|
||||||
|
Если вернуть jsonify({}), app-autotest распарсит но логика та же.
|
||||||
|
Пустое тело — канонический ответ реального Nubes API для validate-cfs.
|
||||||
|
"""
|
||||||
|
if not mock_state.state.get_operation(uid):
|
||||||
|
return jsonify({"error": "operation not found"}), 404
|
||||||
|
|
||||||
|
return "", 200
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""
|
||||||
|
routes/root.py — корневые эндпоинты (/health, /).
|
||||||
|
|
||||||
|
Blueprint "root" регистрируется в app.py БЕЗ url_prefix.
|
||||||
|
Отвечает за healthcheck (для Nubes) и HTML-страницу с информацией о сервисе.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
|
import mock_state
|
||||||
|
from config.loader import SERVICES, DELAY
|
||||||
|
from utils.now import now as _now
|
||||||
|
|
||||||
|
# Версия — показывается на HTML-странице и в healthcheck (опционально)
|
||||||
|
VERSION = "0.2.1"
|
||||||
|
|
||||||
|
# Blueprint без префикса — роуты /health и / будут на корне домена
|
||||||
|
bp = Blueprint("root", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/health")
|
||||||
|
def health():
|
||||||
|
"""Healthcheck для Nubes.
|
||||||
|
|
||||||
|
Платформа периодически дёргает этот эндпоинт. Если вернёт не 200 —
|
||||||
|
контейнер будет перезапущен. Возвращаем просто "OK", без JSON.
|
||||||
|
"""
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/")
|
||||||
|
def index():
|
||||||
|
"""Корневая HTML-страница с информацией о сервисе.
|
||||||
|
|
||||||
|
Показывает: версию, количество загруженных сервисов, количество инстансов,
|
||||||
|
текущую задержку операций. Использует Jinja2-шаблон templates/index.html
|
||||||
|
и внешний CSS из static/style.css.
|
||||||
|
"""
|
||||||
|
svc_count = len(SERVICES)
|
||||||
|
inst_count = len(mock_state.state.instances)
|
||||||
|
return render_template(
|
||||||
|
"index.html",
|
||||||
|
version=VERSION,
|
||||||
|
svc_count=svc_count,
|
||||||
|
inst_count=inst_count,
|
||||||
|
delay=DELAY,
|
||||||
|
)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
routes/run.py — запуск операции.
|
||||||
|
|
||||||
|
Blueprint "run" с префиксом /api/v1/svc.
|
||||||
|
POST /instanceOperations/<uid>/run — выполнить операцию.
|
||||||
|
|
||||||
|
Вынесен в отдельный файл потому что логика run критична и специфична:
|
||||||
|
синхронный sleep, вызов state_machine.apply_effect, простановка dtFinish.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify
|
||||||
|
|
||||||
|
import mock_state
|
||||||
|
import state_machine
|
||||||
|
from config.loader import SERVICES, DELAY
|
||||||
|
from utils.now import now as _now
|
||||||
|
|
||||||
|
bp = Blueprint("run", __name__, url_prefix="/api/v1/svc")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/instanceOperations/<uid>/run", methods=["POST"])
|
||||||
|
def run_operation(uid):
|
||||||
|
"""POST /instanceOperations/{uid}/run — выполнить операцию.
|
||||||
|
|
||||||
|
Алгоритм (синхронный, без потоков):
|
||||||
|
1. Проверить что операция существует (→ 404 если нет)
|
||||||
|
2. Записать dtStart = now()
|
||||||
|
3. sleep(DELAY) — эмуляция времени выполнения
|
||||||
|
4. Вызвать state_machine.apply_effect() — мутировать состояние инстанса
|
||||||
|
5. Записать dtFinish = now(), isSuccessful = True
|
||||||
|
|
||||||
|
После этого GET /instanceOperations/{uid} вернёт dtFinish,
|
||||||
|
и poll_until_done() в app-autotest завершит поллинг.
|
||||||
|
|
||||||
|
⛔ Нет защиты от повторного run — если вызвать дважды, dtStart
|
||||||
|
перезапишется и эффект применится повторно. Для тестовых целей
|
||||||
|
(MOCK_OP_DELAY ≤ 0.5с) вероятность минимальна.
|
||||||
|
|
||||||
|
Возвращает {"ok": True} — формат как в реальном Nubes API.
|
||||||
|
"""
|
||||||
|
op = mock_state.state.get_operation(uid)
|
||||||
|
if not op:
|
||||||
|
return jsonify({"error": "operation not found"}), 404
|
||||||
|
|
||||||
|
# Фиксируем время старта
|
||||||
|
op["dtStart"] = _now()
|
||||||
|
|
||||||
|
# Эмулируем задержку выполнения (по умолчанию 0.1с)
|
||||||
|
if DELAY > 0:
|
||||||
|
time.sleep(DELAY)
|
||||||
|
|
||||||
|
# Применить эффект операции к состоянию инстанса
|
||||||
|
# (create → running+params, delete → удалить, modify → мерж params, ...)
|
||||||
|
state_machine.apply_effect(uid, mock_state.state, SERVICES)
|
||||||
|
|
||||||
|
# Фиксируем время завершения — операция выполнена успешно
|
||||||
|
op["dtFinish"] = _now()
|
||||||
|
op["isSuccessful"] = True
|
||||||
|
|
||||||
|
return jsonify({"ok": True})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
routes/services_routes.py — эндпоинты сервисов.
|
||||||
|
|
||||||
|
Blueprint "services" с префиксом /api/v1/svc/services.
|
||||||
|
GET /services — список всех загруженных сервисов
|
||||||
|
GET /services/<id> — детали конкретного сервиса (список операций)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify
|
||||||
|
|
||||||
|
from config.loader import SERVICES
|
||||||
|
|
||||||
|
bp = Blueprint("services", __name__, url_prefix="/api/v1/svc/services")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("", methods=["GET"])
|
||||||
|
@bp.route("/", methods=["GET"])
|
||||||
|
def list_services():
|
||||||
|
"""GET /api/v1/svc/services — список всех сервисов.
|
||||||
|
|
||||||
|
Возвращает {results: [{svcId, svc, svcShort}, ...]}.
|
||||||
|
Формат совпадает с реальным Nubes API:
|
||||||
|
svcId — числовой ID сервиса (1, 90, 91, ...)
|
||||||
|
svc — человекочитаемое имя (Болванка, PostgreSQL, ...)
|
||||||
|
svcShort — короткое имя (dummy, postgres, ...)
|
||||||
|
"""
|
||||||
|
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})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/<int:svc_id>", methods=["GET"])
|
||||||
|
def get_service(svc_id):
|
||||||
|
"""GET /api/v1/svc/services/{id} — детали сервиса.
|
||||||
|
|
||||||
|
Возвращает {svc: {svc, svcShort, operations: [{svcOperationId, operation}]}}.
|
||||||
|
Если сервис не найден — 404 с JSON-ошибкой.
|
||||||
|
"""
|
||||||
|
svc_def = SERVICES.get(svc_id)
|
||||||
|
if not svc_def:
|
||||||
|
return jsonify({"error": "service not found"}), 404
|
||||||
|
|
||||||
|
# Собрать список операций сервиса (create, modify, delete, suspend, ...)
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -20,13 +20,7 @@ state_machine.py — apply_effect: применение результата о
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _pluralize(word):
|
from utils.pluralize import pluralize as _pluralize
|
||||||
"""Простейшая плюрализация."""
|
|
||||||
if word.endswith("s"):
|
|
||||||
return word
|
|
||||||
if word.endswith("y") and len(word) > 2 and word[-2] not in "aeiou":
|
|
||||||
return word[:-1] + "ies"
|
|
||||||
return word + "s"
|
|
||||||
|
|
||||||
|
|
||||||
def apply_effect(op_uid, mock_state, services):
|
def apply_effect(op_uid, mock_state, services):
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
* polygon — тёмная тема для корневой страницы (/).
|
||||||
|
* Минимальный CSS, без фреймворков.
|
||||||
|
*/
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>polygon v{{ version }}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>polygon <span class="ver">v{{ version }}</span></h1>
|
||||||
|
<p class="ok">● running</p>
|
||||||
|
<p>Сервисов: {{ svc_count }} | Инстансов: {{ inst_count }} | delay: {{ delay }}s</p>
|
||||||
|
<p>Эндпоинты: <code>/api/v1/svc/*</code></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""
|
||||||
|
now.py — утилита времени.
|
||||||
|
|
||||||
|
Единственная функция: now() возвращает текущее UTC-время в ISO-формате с 'Z'
|
||||||
|
(как в реальном Nubes API). Используется в app.py (run_operation) и mock_state.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def now():
|
||||||
|
"""Текущее UTC-время в ISO-формате с суффиксом 'Z'.
|
||||||
|
|
||||||
|
Формат: "2026-07-31T20:30:00.123456Z"
|
||||||
|
Реальное Nubes API возвращает dtFinish/dtCreate именно в таком виде.
|
||||||
|
"""
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""
|
||||||
|
pluralize.py — плюрализация английских слов.
|
||||||
|
|
||||||
|
Единственная функция: pluralize(word) — простейшая эвристика для образования
|
||||||
|
множественного числа. Используется в:
|
||||||
|
- from_stands.py: при генерации state_out_template (user→users, database→databases)
|
||||||
|
- state_machine.py: при apply_effect для subresource-операций
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def pluralize(word):
|
||||||
|
"""Простейшая плюрализация английского слова.
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
- уже заканчивается на 's' → не меняем (status → status)
|
||||||
|
- согласная + 'y' → 'ies' (city → cities)
|
||||||
|
- всё остальное → + 's' (user → users, database → databases)
|
||||||
|
|
||||||
|
НЕ полная морфология — только для predictable subresource-имён из STANDS YAML.
|
||||||
|
"""
|
||||||
|
if word.endswith("s"):
|
||||||
|
return word # уже во множественном (например "status")
|
||||||
|
if word.endswith("y") and len(word) > 2 and word[-2] not in "aeiou":
|
||||||
|
return word[:-1] + "ies" # "category" → "categories"
|
||||||
|
return word + "s" # "user" → "users", "database" → "databases"
|
||||||
Reference in New Issue
Block a user