From 67b4606bc296e463fce39d4ba59a6f8d37b40eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Thu, 23 Jul 2026 12:49:41 +0400 Subject: [PATCH] MVP: config.yaml, pipeline runner, API routes, UI with checkboxes and run button --- requirements.txt | 1 + site/app.py | 2 + site/config.yaml | 28 +++++++ site/routes/api.py | 34 ++++++++ site/routes/main.py | 6 +- site/runner.py | 167 ++++++++++++++++++++++++++++++++++++++ site/templates/index.html | 143 ++++++++++++++++---------------- 7 files changed, 309 insertions(+), 72 deletions(-) create mode 100644 site/config.yaml create mode 100644 site/routes/api.py create mode 100644 site/runner.py diff --git a/requirements.txt b/requirements.txt index 40e5474..b67cb26 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ Flask>=3.0 gunicorn>=21.2 requests>=2.31 +PyYAML>=6.0 diff --git a/site/app.py b/site/app.py index 3ffdb6f..536f840 100644 --- a/site/app.py +++ b/site/app.py @@ -3,11 +3,13 @@ import os from flask import Flask from routes.main import bp as main_bp +from routes.api import bp as api_bp app = Flask(__name__, template_folder="templates", static_folder="static") app.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc") app.config["NUBES_API_TOKEN"] = os.getenv("NUBES_API_TOKEN", "") app.register_blueprint(main_bp) +app.register_blueprint(api_bp) if __name__ == "__main__": diff --git a/site/config.yaml b/site/config.yaml new file mode 100644 index 0000000..2d9de3f --- /dev/null +++ b/site/config.yaml @@ -0,0 +1,28 @@ +test_org_uid: "auto" + +services: + - service_id: 1 + name: dummy + display_name: "autotest-dummy" + enabled: true + operations: + - name: create + enabled: true + params: + durationMs: "0" + failAtStart: "false" + - name: delete + enabled: true + params: {} + + - service_id: 90 + name: PostgreSQL + display_name: "autotest-pg" + enabled: false + operations: + - name: create + enabled: false + params: {} + - name: delete + enabled: false + params: {} diff --git a/site/routes/api.py b/site/routes/api.py new file mode 100644 index 0000000..01f6d45 --- /dev/null +++ b/site/routes/api.py @@ -0,0 +1,34 @@ +import threading + +from flask import Blueprint, current_app, jsonify, request + +from runner import run_tests, get_status, load_config, save_config + +bp = Blueprint("api", __name__) + + +@bp.route("/api/run", methods=["POST"]) +def api_run(): + t = threading.Thread( + target=run_tests, + args=(current_app.config["NUBES_API_ENDPOINT"], current_app.config["NUBES_API_TOKEN"]), + daemon=True, + ) + t.start() + return jsonify({"ok": True}) + + +@bp.route("/api/status") +def api_status(): + return jsonify(get_status() or {}) + + +@bp.route("/api/config", methods=["GET"]) +def api_config_get(): + return jsonify(load_config()) + + +@bp.route("/api/config", methods=["POST"]) +def api_config_save(): + save_config(request.get_json()) + return jsonify({"ok": True}) diff --git a/site/routes/main.py b/site/routes/main.py index 813bfbf..2538224 100644 --- a/site/routes/main.py +++ b/site/routes/main.py @@ -3,6 +3,7 @@ from flask import Blueprint, current_app, render_template, request, make_respons from api.http_client import HttpClient from operations.get_instances import get_organization, get_instances from operations.get_services import get_services, get_service_detail +from runner import load_config bp = Blueprint("main", __name__) @@ -43,6 +44,7 @@ def index(): services = [] instances = [] + config = {} if active_token: try: client = HttpClient( @@ -55,6 +57,7 @@ def index(): instances = get_instances(client) # org first, then rest instances.sort(key=lambda i: (0 if i.get("serviceId") == 19 else 1, i.get("displayName", ""))) + config = load_config() except Exception as e: error = str(e) @@ -63,7 +66,8 @@ def index(): env_token_masked=_mask(env_token), has_user_token=bool(user_token), services=services, - instances=instances) + instances=instances, + config=config) @bp.route("/api/operations/") diff --git a/site/runner.py b/site/runner.py new file mode 100644 index 0000000..396fbcf --- /dev/null +++ b/site/runner.py @@ -0,0 +1,167 @@ +import threading +import time +import yaml +import os + +from api.http_client import HttpClient + + +CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml") + +_current_run = None +_lock = threading.Lock() + + +def load_config(): + with open(CONFIG_PATH) as f: + return yaml.safe_load(f) + + +def save_config(data): + with open(CONFIG_PATH, "w") as f: + yaml.dump(data, f, allow_unicode=True, default_flow_style=False) + + +def get_status(): + return _current_run + + +def _wait_for_ready(client, instance_uid, timeout=300): + deadline = time.time() + timeout + while time.time() < deadline: + data = client.get("/instances", params={"pageSize": 500}) + for inst in data.get("results", []): + if inst.get("instanceUid") == instance_uid: + status = inst.get("explainedStatus", "") + in_progress = inst.get("operationIsInProgress", False) + pending = inst.get("operationIsPending", False) + if not in_progress and not pending and status not in ("creating", "modifying", "deleting"): + return inst + time.sleep(10) + return None + + +def _find_svc_operation_id(client, service_id, operation_name): + data = client.get(f"/services/{service_id}") + for op in data.get("svc", {}).get("operations", []): + if op.get("operation") == operation_name: + return op["svcOperationId"] + return None + + +def run_tests(endpoint, token): + global _current_run + client = HttpClient(endpoint, token) + config = load_config() + + org_uid = config.get("test_org_uid", "auto") + if org_uid == "auto": + from operations.get_instances import get_organization + org = get_organization(client) + org_uid = org["instanceUid"] if org else "" + + run_id = time.strftime("%Y%m%d-%H%M%S") + with _lock: + _current_run = { + "run_id": run_id, + "status": "running", + "started_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + "finished_at": None, + "results": [], + } + + for svc_cfg in config.get("services", []): + if not svc_cfg.get("enabled"): + continue + + service_id = svc_cfg["service_id"] + display_name = svc_cfg.get("display_name", f"autotest-{svc_cfg['name']}") + instance_uid = None + + for op_cfg in svc_cfg.get("operations", []): + if not op_cfg.get("enabled"): + with _lock: + _current_run["results"].append({ + "service_id": service_id, + "service_name": svc_cfg["name"], + "operation": op_cfg["name"], + "status": "SKIP", + "instance_uid": None, + "error": None, + "duration_sec": 0, + }) + continue + + t0 = time.time() + result = { + "service_id": service_id, + "service_name": svc_cfg["name"], + "operation": op_cfg["name"], + "status": "RUNNING", + "instance_uid": None, + "error": None, + "duration_sec": 0, + } + with _lock: + _current_run["results"].append(result) + + try: + svc_op_id = _find_svc_operation_id(client, service_id, op_cfg["name"]) + if not svc_op_id: + raise Exception(f"Operation {op_cfg['name']} not found") + + if op_cfg["name"] == "create": + payload = { + "serviceId": service_id, + "displayName": display_name, + } + params = op_cfg.get("params", {}) + if params: + cfs_params = [{"svcOperationCfsParamId": int(k), "paramValue": str(v)} for k, v in params.items()] + payload["cfsParams"] = cfs_params + create_resp = client.post("/instances", payload) + instance_uid = create_resp.get("instanceUid") or _extract_uid_from_response(create_resp) + if not instance_uid: + raise Exception("No instanceUid in create response") + inst = _wait_for_ready(client, instance_uid) + if not inst: + raise Exception("Timeout waiting for create") + else: + if not instance_uid: + raise Exception(f"No instance for {op_cfg['name']}") + payload = { + "instanceUid": instance_uid, + "svcOperationId": svc_op_id, + } + params = op_cfg.get("params", {}) + if params: + payload["cfsParams"] = [{"svcOperationCfsParamId": int(k), "paramValue": str(v)} for k, v in params.items()] + client.post("/instanceOperations", payload) + inst = _wait_for_ready(client, instance_uid) + if not inst: + raise Exception(f"Timeout waiting for {op_cfg['name']}") + + result["status"] = "OK" + result["instance_uid"] = instance_uid + except Exception as e: + result["status"] = "FAIL" + result["error"] = str(e) + if op_cfg["name"] == "create": + break # дальше операции этого сервиса бессмысленны + finally: + result["duration_sec"] = round(time.time() - t0, 1) + + # wait between services + time.sleep(1) + + with _lock: + _current_run["status"] = "done" + _current_run["finished_at"] = time.strftime("%Y-%m-%dT%H:%M:%S") + + +def _extract_uid_from_response(resp): + if isinstance(resp, dict): + for v in resp.values(): + if isinstance(v, str) and len(v) == 36 and v.count("-") == 4: + return v + return None diff --git a/site/templates/index.html b/site/templates/index.html index 413e225..bb8183f 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -11,7 +11,6 @@ .left { width:380px; flex-shrink:0; } .right { flex:1; min-width:0; } .svc-row { cursor:pointer; } - .inst-row { cursor:default; } @@ -26,8 +25,6 @@
- -
{% if organization %}
@@ -41,20 +38,13 @@
{% endif %} - {% if instances %}
-
- Инстансы ({{ instances|length }}) -
+
Инстансы ({{ instances|length }})
{% for i in instances %} - - - - - + {% endfor %}
{{ i.displayName }}{{ i.svc }}{{ i.explainedStatus or "?" }}
{{ i.displayName }}{{ i.svc }}{{ i.explainedStatus or "?" }}
@@ -62,33 +52,27 @@ {% endif %}
-
+
+ + +
{% if services %}
-
- Сервисы ({{ services|length }}) -
- - - + \ No newline at end of file