#!/usr/bin/env python3 """ Универсальный сборщик state_params для сервисов тест-стенда. Создаёт инстанс, снимает параметры, суспендит. Использование: python3 collect_service_params.py [display_name] """ import json, sys, time, urllib.request, urllib.error, urllib.parse, os, yaml, html API = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm" TOKEN = open("/home/naeel/terra/terraform/secrets/test.token").read().strip() OUT_DIR = "/home/naeel/terra/terraform/devops/profiles/test/generated/output_inventory" HEADERS = { "Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json", } # Минимальные значения по умолчанию для стандартных параметров PARAM_DEFAULTS = { "resourceRealm": "k8s-3-sandbox-nubes-ru", "resourceCPU": "1000", "resourceMemory": "1024", "resourceDisk": "10", "resourceInstances": "1", "resourceInstancesShards": "1", "needExternalAddressMaster": "false", "needExternalAddressSlave": "false", "needCreateKeeper": "false", "autoScale": "false", "durationMs": "10000", "failAtStart": "false", "failInProgress": "false", "domain": "web-test-stand", "appVersion": "latest", "jsonEnv": "{}", "login": "admin@ngcloud.ru", "password": "Admin123!", "gitPath": "https://github.com/Foxyhhd/Baldurs-Gate-test.git", # Для dummy "mapExample": "{}", "jsonExample": "{}", "yamlExample": "", "bodymessage": "", "whereFail": "1", } # Переопределения для конкретных сервисов {service_id: {code: value}} SVC_PARAM_OVERRIDES = { 97: {"domain": "inv-nodered"}, 110: {"zoneEmail": "admin@ngcloud.ru", "zoneName": "inv-test.ngcloud.ru"}, 117: {"domain": "inv-nifi"}, 99: {"domain": "inv-gitea"}, 82: {"domain": "inv-harbor"}, } def req(method, path, body=None): url = API + path data = json.dumps(body).encode() if body is not None else None r = urllib.request.Request(url, data=data, headers=HEADERS, method=method) try: with urllib.request.urlopen(r, timeout=30) as resp: raw = resp.read() loc = resp.headers.get("Location", "") return json.loads(raw) if raw else {}, loc except urllib.error.HTTPError as e: raw = e.read() print(f" HTTP {e.code} {method} {path}: {raw[:300]}") return None, "" def get(path): result, _ = req("GET", path) return result def post(path, body): return req("POST", path, body) def wait_op(op_uid, timeout=300): deadline = time.time() + timeout while time.time() < deadline: data = get(f"/instanceOperations/{op_uid}?fields=dtFinish,isSuccessful,errorLog,isInProgress,isPending") if data is None: return False, "request error" op = data.get("instanceOperation", data) in_prog = op.get("isInProgress", True) pending = op.get("isPending", True) if not in_prog and not pending: success = op.get("isSuccessful", False) err = op.get("errorLog", "") return success, err time.sleep(5) return False, "timeout" def create_instance(service_id, display_name): print(f"\n=== Создаём инстанс: svc={service_id}, name={display_name} ===") # 0. Загружаем YAML чтобы знать code для каждого param_id import glob as _glob yaml_dir = "/home/naeel/terra/terraform/devops/profiles/test/generated/resources_yaml" yaml_files = _glob.glob(yaml_dir + "/*.yaml") param_id_to_code = {} for yf in yaml_files: ydata = yaml.safe_load(open(yf)) if ydata.get("service_id") != service_id: continue for op in ydata.get("operations", []): for p in op.get("params", []): pid = p.get("id") code = p.get("code", "") if pid and code: param_id_to_code[pid] = code break print(f" YAML param id→code map: {len(param_id_to_code)} entries") # 1. Создаём инстанс resp, loc = post("/instances", { "serviceId": service_id, "displayName": display_name, "descr": "inventory-collector", }) if resp is None: return None if isinstance(resp, dict): instance_uid = resp.get("instanceUid") else: instance_uid = None if not instance_uid and loc: instance_uid = loc.rstrip("/").split("/")[-1] if not instance_uid: print(f" ERR: нет instanceUid. resp={resp}, loc={loc}") return None print(f" instanceUid: {instance_uid}") # 2. Создаём операцию create resp, loc = post("/instanceOperations", { "instanceUid": instance_uid, "operation": "create", }) if resp is None: return None op = resp.get("instanceOperation", resp) if isinstance(resp, dict) else {} op_uid = op.get("instanceOperationUid") if not op_uid and loc: op_uid = loc.rstrip("/").split("/")[-1] if not op_uid: print(f" ERR: нет opUid. resp={resp}, loc={loc}") return None print(f" opUid: {op_uid}") # 3. Получаем cfsParams с ID op_detail = get(f"/instanceOperations/{op_uid}?fields=cfsParams") if op_detail is None: return None cfs_params = (op_detail.get("instanceOperation") or op_detail).get("cfsParams", []) print(f" cfsParams count: {len(cfs_params)}") for p in cfs_params: code_display = p.get("svcOperationCfsParam") or p.get("code", "?") print(f" id={p['svcOperationCfsParamId']} code={code_display} required={p.get('isRequired')} default={p.get('defaultValue')} paramValue={p.get('paramValue')}") # 4. Заполняем параметры (POST для КАЖДОГО — иначе /run ломается с GUID-ошибкой) filled = 0 for p in cfs_params: param_id = p["svcOperationCfsParamId"] # Получаем code: сначала из прямого поля API, потом из YAML-маппинга code = p.get("svcOperationCfsParam") or param_id_to_code.get(param_id, p.get("code", "")) current = p.get("paramValue") default = p.get("defaultValue") # Переопределения для конкретного сервиса имеют наивысший приоритет overrides = SVC_PARAM_OVERRIDES.get(service_id, {}) # Определяем значение # Пропускаем pre-populated невалидные значения ('""', пустые строки) if current is not None and str(current) not in ('""', "''", ""): value = str(current) elif code in overrides: value = overrides[code] elif default is not None and str(default).strip() != "": # HTML-unescape дефолтов (API может вернуть " и т.п.) value = html.unescape(str(default)) elif code in PARAM_DEFAULTS: value = PARAM_DEFAULTS[code] else: value = "" # Отправляем пустую строку — сервер требует запись для каждого параметра resp2, loc2 = post("/instanceOperationCfsParams", { "instanceOperationUid": op_uid, "svcOperationCfsParamId": param_id, "paramValue": value, }) if resp2 is not None and (loc2 or resp2 != {}): filled += 1 elif resp2 is None: print(f" WARN: param id={param_id} code={code} не принят API (400/422), value={value!r}") print(f" Заполнено параметров: {filled}") # 5. validate-cfs — GET (в HAR это GET, не POST; на test может вернуть 405 — терпим) req("GET", f"/instanceOperations/{op_uid}/validate-cfs") # 6. Run resp4, _ = post(f"/instanceOperations/{op_uid}/run", {}) if resp4 is None: print(" ERR: run вернул ошибку — сервис может быть сырым/нерабочим") return instance_uid # возвращаем uid — state может быть частичным print(" Операция запущена, ждём завершения...") # 7. Ждём success, err = wait_op(op_uid, timeout=600) if not success: print(f" WARN: операция завершилась с ошибкой (сервис сырой?): {err[:200]}") # Не прерываемся — возвращаем uid, state может содержать частичные данные else: print(" Создание завершено успешно!") return instance_uid def collect_state(instance_uid): detail = get(f"/instances/{instance_uid}?fields=instanceUid,displayName,serviceId,svc,state,explainedStatus") if not detail: return None inst = detail.get("instance", {}) state = inst.get("state", {}) or {} return { "uid": instance_uid, "name": inst.get("displayName",""), "serviceId": inst.get("serviceId"), "svc_code": inst.get("svc",""), "status": inst.get("explainedStatus",""), "params": state.get("params",{}) or {}, "out": state.get("out",{}) or {}, "vault_keys": list((state.get("vault",{}) or {}).keys()), } def suspend_instance(instance_uid): print(f" Суспендим {instance_uid}...") resp, loc = post("/instanceOperations", { "instanceUid": instance_uid, "operation": "suspend", }) if resp is None: return op = resp.get("instanceOperation", resp) if isinstance(resp, dict) else {} op_uid = op.get("instanceOperationUid") if not op_uid and loc: op_uid = loc.rstrip("/").split("/")[-1] if not op_uid: return post(f"/instanceOperations/{op_uid}/run", {}) success, err = wait_op(op_uid, timeout=300) if success: print(" Suspended!") else: print(f" WARN suspend: {err}") def save_to_inventory(state_data): svc_id = state_data["serviceId"] safe = state_data["svc_code"].replace("/","_").replace(" ","_") fname = os.path.join(OUT_DIR, f"svc_{svc_id}_{safe}.json") existing = {} if os.path.exists(fname): existing = json.load(open(fname)) instances = existing.get("instances", []) # Не дублируем uids = {i["uid"] for i in instances} if state_data["uid"] not in uids: instances.append(state_data) result = { "serviceId": svc_id, "svc_code": state_data["svc_code"], "instances": instances, } with open(fname, "w") as f: json.dump(result, f, indent=2, ensure_ascii=False) print(f" Saved: {fname}") # Обновляем индекс rebuild_index() def rebuild_index(): import glob index = [] for f in sorted(glob.glob(os.path.join(OUT_DIR, "svc_*.json"))): data = json.load(open(f)) all_params = set() all_out = set() all_vault = set() for item in data.get("instances", []): all_params.update((item.get("params") or {}).keys()) out = item.get("out") all_out.update((out.keys() if isinstance(out, dict) else [])) all_vault.update(item.get("vault_keys", [])) index.append({ "serviceId": data["serviceId"], "svc_code": data["svc_code"], "instance_count": len(data.get("instances", [])), "state_params_keys": sorted(all_params), "state_out_keys": sorted(all_out), "vault_keys": sorted(all_vault), }) with open(os.path.join(OUT_DIR, "index.json"), "w") as f: json.dump(index, f, indent=2, ensure_ascii=False) # ===== MAIN ===== if __name__ == "__main__": if len(sys.argv) < 2: print("Usage: collect_service_params.py [display_name]") sys.exit(1) service_id = int(sys.argv[1]) display_name = sys.argv[2] if len(sys.argv) > 2 else f"inventory-svc{service_id}" instance_uid = create_instance(service_id, display_name) if not instance_uid: print("FAILED: не удалось создать инстанс") sys.exit(1) # Небольшая пауза перед чтением state time.sleep(3) state = collect_state(instance_uid) if state: print(f"\n state_params keys: {list(state['params'].keys())}") print(f" state_out keys: {list(state['out'].keys()) if isinstance(state['out'], dict) else '-'}") print(f" vault keys: {state['vault_keys']}") save_to_inventory(state) else: print("WARN: не удалось получить state") suspend_instance(instance_uid) print("\nDone.")