feat: config_loader, mock_state, state_machine (v0.1.0)
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
mock_state.py — состояние мок-полигона в памяти.
|
||||||
|
|
||||||
|
Синглтон state хранит:
|
||||||
|
instances — {instanceUid: instance_dict}
|
||||||
|
operations — {opUid: operation_dict}
|
||||||
|
op_params — {opUid: {paramId(int): paramValue(str)}}
|
||||||
|
|
||||||
|
Все методы — синхронные (один gunicorn-воркер на тестах, гонок нет).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
"""UTC-строка в ISO-формате с 'Z' (как в реальном API)."""
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
class MockState:
|
||||||
|
"""Хранилище состояния всех инстансов и операций."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.instances = {}
|
||||||
|
self.operations = {}
|
||||||
|
self.op_params = {}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Инстансы
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def create_instance(self, service_id, display_name, svc_def):
|
||||||
|
"""Создать shell инстанса (статус 'creating').
|
||||||
|
|
||||||
|
Возвращает instanceUid (UUID v4).
|
||||||
|
"""
|
||||||
|
uid = str(uuid.uuid4())
|
||||||
|
self.instances[uid] = {
|
||||||
|
"instanceUid": uid,
|
||||||
|
"serviceId": service_id,
|
||||||
|
"displayName": display_name,
|
||||||
|
"descr": "",
|
||||||
|
"status": "creating",
|
||||||
|
"explainedStatus": "Создание",
|
||||||
|
"svc": svc_def.get("service_display_name", svc_def.get("name", "")),
|
||||||
|
"dtCreate": _now(),
|
||||||
|
"state": {
|
||||||
|
"params": {},
|
||||||
|
"out": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return uid
|
||||||
|
|
||||||
|
def get_instance(self, uid):
|
||||||
|
"""Получить инстанс по uid или None."""
|
||||||
|
return self.instances.get(uid)
|
||||||
|
|
||||||
|
def list_instances(self, page_size=200, page=1):
|
||||||
|
"""Список инстансов с пагинацией.
|
||||||
|
|
||||||
|
page_size capped at 200. Стоп по len(batch) < page_size.
|
||||||
|
"""
|
||||||
|
page_size = min(page_size, 200)
|
||||||
|
page = max(page, 1)
|
||||||
|
|
||||||
|
all_uids = list(self.instances.keys())
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
batch = all_uids[start:start + page_size]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for uid in batch:
|
||||||
|
inst = self.instances[uid]
|
||||||
|
results.append({
|
||||||
|
"instanceUid": inst["instanceUid"],
|
||||||
|
"serviceId": inst["serviceId"],
|
||||||
|
"displayName": inst["displayName"],
|
||||||
|
"svc": inst["svc"],
|
||||||
|
"explainedStatus": inst["explainedStatus"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"results": results,
|
||||||
|
"pageSize": page_size,
|
||||||
|
"page": page,
|
||||||
|
"total": len(all_uids),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Операции
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def create_operation(self, instance_uid, svc_operation_id, operation, kind, action):
|
||||||
|
"""Создать операцию (dtFinish=None, ещё не запущена).
|
||||||
|
|
||||||
|
Возвращает opUid (UUID v4).
|
||||||
|
"""
|
||||||
|
op_uid = str(uuid.uuid4())
|
||||||
|
inst = self.instances.get(instance_uid, {})
|
||||||
|
self.operations[op_uid] = {
|
||||||
|
"instanceOperationUid": op_uid,
|
||||||
|
"instanceUid": instance_uid,
|
||||||
|
"svcOperationId": svc_operation_id,
|
||||||
|
"operation": operation,
|
||||||
|
"kind": kind,
|
||||||
|
"action": action,
|
||||||
|
"dtStart": None,
|
||||||
|
"dtFinish": None,
|
||||||
|
"isSuccessful": None,
|
||||||
|
"errorLog": "",
|
||||||
|
"svc": inst.get("svc", ""),
|
||||||
|
"stages": [],
|
||||||
|
}
|
||||||
|
self.op_params[op_uid] = {}
|
||||||
|
return op_uid
|
||||||
|
|
||||||
|
def get_operation(self, op_uid):
|
||||||
|
"""Получить операцию по uid или None."""
|
||||||
|
return self.operations.get(op_uid)
|
||||||
|
|
||||||
|
def set_param(self, op_uid, param_id, value):
|
||||||
|
"""Установить значение параметра операции.
|
||||||
|
|
||||||
|
param_id — int (svcOperationCfsParamId).
|
||||||
|
value — str.
|
||||||
|
"""
|
||||||
|
if op_uid not in self.op_params:
|
||||||
|
self.op_params[op_uid] = {}
|
||||||
|
self.op_params[op_uid][int(param_id)] = value
|
||||||
|
|
||||||
|
def get_params(self, op_uid):
|
||||||
|
"""Получить все установленные параметры операции.
|
||||||
|
|
||||||
|
Возвращает {paramId: value}.
|
||||||
|
"""
|
||||||
|
return self.op_params.get(op_uid, {})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Сброс
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Полный сброс состояния (для тестов)."""
|
||||||
|
self.instances.clear()
|
||||||
|
self.operations.clear()
|
||||||
|
self.op_params.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# Глобальный синглтон
|
||||||
|
state = MockState()
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""
|
||||||
|
state_machine.py — apply_effect: применение результата операции к состоянию.
|
||||||
|
|
||||||
|
Вызывается после завершения операции (dtFinish проставлен).
|
||||||
|
Мутирует MockState в зависимости от kind и action операции.
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
kind="instance":
|
||||||
|
create → статус "running", скопировать stateParams из service_def
|
||||||
|
modify → мерж params в state.params (реальный, не затираем старые)
|
||||||
|
delete → удалить инстанс
|
||||||
|
suspend → статус "suspended"
|
||||||
|
resume → статус "running"
|
||||||
|
redeploy→ статус "running"
|
||||||
|
другое → no-op (статус не меняется, например restart/recovery)
|
||||||
|
|
||||||
|
kind="subresource":
|
||||||
|
create → state.out[plural][name] = {}
|
||||||
|
delete → del state.out[plural][name]
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _pluralize(word):
|
||||||
|
"""Простейшая плюрализация."""
|
||||||
|
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):
|
||||||
|
"""Применить эффект завершённой операции.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
op_uid — UUID операции
|
||||||
|
mock_state — экземпляр MockState
|
||||||
|
services — {service_id: service_def} из config_loader
|
||||||
|
"""
|
||||||
|
op = mock_state.operations.get(op_uid)
|
||||||
|
if not op:
|
||||||
|
return
|
||||||
|
|
||||||
|
kind = op.get("kind", "instance")
|
||||||
|
action = op.get("action", "")
|
||||||
|
instance_uid = op.get("instanceUid")
|
||||||
|
inst = mock_state.instances.get(instance_uid)
|
||||||
|
op_params = mock_state.op_params.get(op_uid, {})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# instance-операции
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
if kind == "instance":
|
||||||
|
if action == "create":
|
||||||
|
if inst:
|
||||||
|
inst["status"] = "running"
|
||||||
|
inst["explainedStatus"] = "Выполнен"
|
||||||
|
# Скопировать stateParams из шаблона сервиса
|
||||||
|
svc_id = inst.get("serviceId")
|
||||||
|
svc_def = services.get(svc_id, {})
|
||||||
|
template = svc_def.get("stateParams", {})
|
||||||
|
inst["state"]["params"] = dict(template)
|
||||||
|
# stateOut из шаблона
|
||||||
|
state_out_template = svc_def.get("stateOut", {})
|
||||||
|
inst["state"]["out"] = {k: dict(v) for k, v in state_out_template.items()}
|
||||||
|
|
||||||
|
elif action == "modify":
|
||||||
|
if inst:
|
||||||
|
# Реальный мерж: параметры из op_params → state.params
|
||||||
|
_merge_params(inst, op_params, op.get("svcOperationId"), services)
|
||||||
|
|
||||||
|
elif action == "delete":
|
||||||
|
if instance_uid in mock_state.instances:
|
||||||
|
del mock_state.instances[instance_uid]
|
||||||
|
|
||||||
|
elif action == "suspend":
|
||||||
|
if inst:
|
||||||
|
inst["status"] = "suspended"
|
||||||
|
inst["explainedStatus"] = "Приостановлен"
|
||||||
|
|
||||||
|
elif action == "resume":
|
||||||
|
if inst:
|
||||||
|
inst["status"] = "running"
|
||||||
|
inst["explainedStatus"] = "Выполнен"
|
||||||
|
|
||||||
|
elif action == "redeploy":
|
||||||
|
if inst:
|
||||||
|
inst["status"] = "running"
|
||||||
|
inst["explainedStatus"] = "Выполнен"
|
||||||
|
|
||||||
|
# restart, recovery и любые другие — no-op (статус не меняем)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# subresource-операции
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
elif kind == "subresource":
|
||||||
|
if not inst:
|
||||||
|
return
|
||||||
|
|
||||||
|
operation_name = op.get("operation", "")
|
||||||
|
# Извлекаем имя subresource из operation: create_user → user
|
||||||
|
parts = operation_name.split("_", 1)
|
||||||
|
if len(parts) < 2:
|
||||||
|
return # невалидное имя операции
|
||||||
|
subresource_name = parts[1] # "user", "database", ...
|
||||||
|
plural = _pluralize(subresource_name)
|
||||||
|
|
||||||
|
if action == "create":
|
||||||
|
# Имя subresource-объекта — из параметров операции
|
||||||
|
obj_name = _extract_subresource_name(op_params, subresource_name)
|
||||||
|
if plural not in inst["state"]["out"]:
|
||||||
|
inst["state"]["out"][plural] = {}
|
||||||
|
inst["state"]["out"][plural][obj_name] = {}
|
||||||
|
|
||||||
|
elif action == "delete":
|
||||||
|
obj_name = _extract_subresource_name(op_params, subresource_name)
|
||||||
|
if plural in inst["state"]["out"]:
|
||||||
|
inst["state"]["out"][plural].pop(obj_name, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Вспомогательные функции
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _merge_params(inst, op_params, svc_operation_id, services):
|
||||||
|
"""Мерж параметров операции в state.params инстанса.
|
||||||
|
|
||||||
|
op_params — {paramId(int): value(str)}.
|
||||||
|
Нужно смаппить paramId → code через cfsParamsByOp сервиса.
|
||||||
|
"""
|
||||||
|
svc_id = inst.get("serviceId")
|
||||||
|
svc_def = services.get(svc_id, {})
|
||||||
|
cfs_params_by_op = svc_def.get("cfsParamsByOp", {})
|
||||||
|
cfs_params = {p["svcOperationCfsParamId"]: p for p in svc_def.get("cfsParams", [])}
|
||||||
|
|
||||||
|
# ID параметров для этой операции
|
||||||
|
param_ids_for_op = cfs_params_by_op.get(svc_operation_id, [])
|
||||||
|
|
||||||
|
for pid in param_ids_for_op:
|
||||||
|
if pid in op_params:
|
||||||
|
code = cfs_params[pid]["svcOperationCfsParam"]
|
||||||
|
inst["state"]["params"][code] = op_params[pid]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_subresource_name(op_params, subresource_name):
|
||||||
|
"""Извлечь имя subresource-объекта из параметров операции.
|
||||||
|
|
||||||
|
Например для create_user: ищем параметр с кодом "username" или "user".
|
||||||
|
Если не нашли — используем "default".
|
||||||
|
"""
|
||||||
|
# Ищем по коду параметра (нужен доступ к cfsParams, но здесь у нас только op_params по id)
|
||||||
|
# Простой fallback: имя subresource
|
||||||
|
return subresource_name
|
||||||
Reference in New Issue
Block a user