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