feat: config_loader, mock_state, state_machine (v0.1.0)

This commit is contained in:
2026-07-31 20:21:11 +04:00
parent 77f12d2f86
commit 1956fb7835
3 changed files with 355 additions and 0 deletions
+52
View File
@@ -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
+150
View File
@@ -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()
+153
View File
@@ -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