diff --git a/site/config_loader.py b/site/config_loader.py new file mode 100644 index 0000000..42f65eb --- /dev/null +++ b/site/config_loader.py @@ -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 diff --git a/site/mock_state.py b/site/mock_state.py new file mode 100644 index 0000000..6ed737e --- /dev/null +++ b/site/mock_state.py @@ -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() diff --git a/site/state_machine.py b/site/state_machine.py new file mode 100644 index 0000000..b048018 --- /dev/null +++ b/site/state_machine.py @@ -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