`;
});
// Показать в ПОСЛЕДНЕМ контейнере этапов (самом новом)
- const boxes=document.querySelectorAll('.stages-box');
- if(boxes.length) boxes[boxes.length-1].innerHTML=html;
+ const boxes = document.querySelectorAll('.stages-box');
+ if (boxes.length) boxes[boxes.length - 1].innerHTML = html;
}
diff --git a/site/static/js/scenario-form.js b/site/static/js/scenario-form.js
index b2dbc12..72e0d2a 100644
--- a/site/static/js/scenario-form.js
+++ b/site/static/js/scenario-form.js
@@ -38,6 +38,8 @@ async function showScenarioEditor(def) {
service_id: s.service_id || '', operation: s.operation || '',
output: s.output || '', instance_ref: s.instance_ref || '',
instance_uid: s.instance_uid || '',
+ _legacyInstanceFallback: s.operation !== 'create' && !s.instance_ref && !s.instance_uid,
+ _showCloud: Boolean(s.instance_uid),
params: s.params || {} // {name: value} — символьные имена
})),
services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {}
@@ -58,6 +60,10 @@ async function showScenarioEditor(def) {
scenarioEditorState.services = [];
scenarioEditorState.cloudInstances = [];
}
+ const initialServiceIds = [...new Set(scenarioEditorState.steps
+ .map(step => parseInt(step.service_id, 10))
+ .filter(serviceId => serviceId > 0))];
+ await Promise.all(initialServiceIds.map(loadServiceOperations));
renderEditor();
}
@@ -138,6 +144,30 @@ function snapshotAllParams() {
// Резолв операции + загрузка параметров
// ═══════════════════════════════════════════════════════
+/**
+ * Загрузить доступные операции сервиса и сохранить их в кеше редактора.
+ *
+ * @param {number|string} svcId — ID сервиса
+ * @returns {Promise} операции сервиса или null при ошибке загрузки
+ */
+async function loadServiceOperations(svcId) {
+ if (!svcId) return null;
+ const st = scenarioEditorState;
+ if (Object.prototype.hasOwnProperty.call(st.opsCache, svcId)) {
+ return st.opsCache[svcId];
+ }
+
+ try {
+ const response = await fetch('/api/operations/' + svcId);
+ const data = await response.json();
+ if (!response.ok || data.error) return null;
+ st.opsCache[svcId] = Array.isArray(data.operations) ? data.operations : [];
+ return st.opsCache[svcId];
+ } catch (e) {
+ return null;
+ }
+}
+
/**
* Найти svcOperationId для операции в шаге.
*
@@ -179,15 +209,8 @@ async function resolveStepSvcOpId(idx) {
if (!svcId) return null;
- // Кеш операций сервиса
- if (!st.opsCache[svcId]) {
- try {
- const r = await fetch('/api/operations/' + svcId);
- const d = await r.json();
- st.opsCache[svcId] = d.operations || [];
- } catch (e) { return null; }
- }
- const ops = st.opsCache[svcId];
+ const ops = await loadServiceOperations(svcId);
+ if (!ops) return null;
const found = ops.find(o => o.operation === op);
return found ? found.svcOperationId : null;
}
@@ -230,7 +253,7 @@ async function loadStepParams(idx) {
let allInst = st.cloudInstances || [];
const needRefs = defs.some(p => p.refSvcId);
if (needRefs && !allInst.length) {
- try { const r = await fetch('/api/instances/list'); allInst = await r.json(); } catch (e) {}
+ try { const r = await fetch('/api/instances/list'); allInst = await r.json(); } catch (e) { }
}
// Рендер: клонируем def и подставляем сохранённые значения в defaultValue
@@ -320,20 +343,20 @@ function renderStepRow(idx, step) {
const st = scenarioEditorState;
const op = step.operation;
const out = step.output || '';
- const ref = step.instance_ref || '';
+ const ref = step.instance_ref || step.instance_uid || '';
// Собрать доступные output-ы из предыдущих create-шагов (для instance_ref select)
const prevOutputs = [];
for (let i = 0; i < idx; i++) {
const s = st.steps[i];
- if (s.operation === 'create' && s.output) prevOutputs.push(s.output);
+ if (s.operation === 'create' && s.output && Number(s.service_id) === Number(step.service_id)) prevOutputs.push(s.output);
}
let html = `
`;
// Заголовок шага: номер + кнопки [↑][↓][✕]
html += `
`;
- html += `Шаг ${idx+1}`;
+ html += `Шаг ${idx + 1}`;
// Кнопка вверх (кроме первого)
if (idx > 0) html += ``;
// Кнопка вниз (кроме последнего)
@@ -342,20 +365,30 @@ function renderStepRow(idx, step) {
html += ``;
html += `
`;
- // Выпадающий список операций
- const allOps = ['create', 'delete', 'modify', 'suspend', 'resume', 'redeploy'];
+ // Список операций зависит от актуального каталога выбранного сервиса.
+ let svcOpts = '';
+ st.services.forEach(s => {
+ svcOpts += ``;
+ });
+ html += ``;
+
+ const svcId = parseInt(step.service_id, 10) || 0;
+ const serviceOps = svcId ? (st.opsCache[svcId] || []) : [];
+ const operationsLoaded = svcId && Object.prototype.hasOwnProperty.call(st.opsCache, svcId);
let opOpts = '';
- allOps.forEach(o => { opOpts += ``; });
- html += ``;
+ serviceOps.filter(item => item.operation !== 'reconcile').forEach(item => {
+ const operationName = item.operation || '';
+ opOpts += ``;
+ });
+ if (op && !serviceOps.some(item => item.operation === op)) {
+ opOpts += ``;
+ }
+ const opDisabled = !svcId || !operationsLoaded;
+ html += ``;
// Разное содержимое в зависимости от типа операции
if (op === 'create') {
- // CREATE: выбор сервиса + output name
- let svcOpts = '';
- st.services.forEach(s => {
- svcOpts += ``;
- });
- html += ``;
+ // CREATE: output name
html += ``;
} else if (op) {
@@ -371,13 +404,17 @@ function renderStepRow(idx, step) {
st.cloudInstances.forEach(i => {
const dn = i.displayName || '';
// Только autotest-*, не deleted
- if (!dn.startsWith('autotest-') || i.explainedStatus === 'deleted') return;
+ if (!dn.startsWith('autotest-') || i.explainedStatus === 'deleted'
+ || Number(i.serviceId) !== Number(step.service_id)) return;
const sel = i.instanceUid == ref ? 'selected' : '';
- refOpts += ``;
+ refOpts += ``;
});
}
+ if (step.instance_uid && !st.cloudInstances.some(i => i.instanceUid === step.instance_uid)) {
+ refOpts += ``;
+ }
const btnLabel = step._showCloud ? '➖ Скрыть облако' : '📥 Из облака';
- html += `
`;
+ html += `
`;
html += `
`;
}
@@ -392,6 +429,24 @@ function renderStepRow(idx, step) {
// Действия редактора
// ═══════════════════════════════════════════════════════
+/** Сменить сервис шага и загрузить его актуальный список операций. */
+async function onServiceChange(idx) {
+ snapshotAllParams();
+ const step = scenarioEditorState.steps[idx];
+ const svcEl = document.getElementById('step-' + idx + '-svc');
+ const svcId = parseInt(svcEl?.value, 10) || '';
+ step.service_id = svcId;
+ step.operation = '';
+ step.instance_ref = '';
+ step.instance_uid = '';
+ step.params = {};
+ step._svcOpId = null;
+ step._legacyInstanceFallback = false;
+
+ if (svcId) await loadServiceOperations(svcId);
+ renderEditor();
+}
+
/**
* Изменение операции в выпадающем списке → сохранить выбор + сброс + перерисовка.
*
@@ -407,6 +462,7 @@ function onOpChange(idx) {
step.operation = newOp; // сохранить выбранную операцию
step.params = {}; // сброс параметров (для новой операции — другие)
step._svcOpId = null; // сброс кеша svcOperationId
+ step._legacyInstanceFallback = false;
// Для не-create: автоматически показать облачные инстансы
if (newOp && newOp !== 'create') {
@@ -484,10 +540,11 @@ function collectFormSteps() {
let svcId = 0;
const s = { operation, params: {} };
+ const svcEl = document.getElementById('step-' + idx + '-svc');
+ svcId = parseInt(svcEl?.value) || 0;
+
if (operation === 'create') {
- // CREATE: service_id из select
- const svcEl = document.getElementById('step-' + idx + '-svc');
- svcId = parseInt(svcEl?.value) || 0;
+ // CREATE: service_id из select + output-ссылка
const outEl = document.getElementById('step-' + idx + '-output');
const output = (outEl?.value || '').trim();
if (output) s.output = output;
@@ -498,17 +555,12 @@ function collectFormSteps() {
if (ref) {
const cloud = (st.cloudInstances || []).find(i => i.instanceUid === ref);
if (cloud) {
- svcId = cloud.serviceId;
s.instance_uid = ref; // облачный UUID
+ } else if (ref === step.instance_uid) {
+ s.instance_uid = ref; // сохранить UID даже если инстанс уже не в списке
} else {
- // output-ссылка — ищем service_id в предыдущих create-шагах
+ // output-ссылка на предыдущий create-шаг
s.instance_ref = ref;
- for (let j = 0; j < idx; j++) {
- if (st.steps[j].output === ref) {
- svcId = st.steps[j].service_id;
- break;
- }
- }
}
}
}
@@ -549,14 +601,11 @@ async function saveScenario() {
if (!steps.length) { alert('Добавьте хотя бы один шаг'); return; }
for (let i = 0; i < steps.length; i++) {
const s = steps[i];
- if (!s.operation) { alert('Шаг ' + (i+1) + ': выберите операцию'); return; }
- // CREATE: нужен service_id. Не-CREATE: service_id ИЛИ instance_ref/instance_uid
- if (s.operation === 'create') {
- if (!s.service_id) { alert('Шаг ' + (i+1) + ': выберите сервис'); return; }
- } else {
- if (!s.service_id && !s.instance_ref && !s.instance_uid) {
- alert('Шаг ' + (i+1) + ': выберите инстанс (из create-шага или облака)'); return;
- }
+ if (!s.operation) { alert('Шаг ' + (i + 1) + ': выберите операцию'); return; }
+ if (!s.service_id) { alert('Шаг ' + (i + 1) + ': выберите сервис'); return; }
+ if (s.operation !== 'create' && !s.instance_ref && !s.instance_uid
+ && !scenarioEditorState.steps[i]._legacyInstanceFallback) {
+ alert('Шаг ' + (i + 1) + ': выберите инстанс (из create-шага или облака)'); return;
}
}
diff --git a/tests/conftest.py b/tests/conftest.py
index cb535e1..51f3f50 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +1,6 @@
from pathlib import Path
+import os
+import socket
import subprocess
import sys
import time
@@ -14,17 +16,21 @@ if str(SITE_DIR) not in sys.path:
# Путь к polygon: соседняя репа в корне autotest
POLYGON_DIR = Path(__file__).resolve().parents[2] / "polygon" / "site"
-POLYGON_URL = "http://localhost:5000"
@pytest.fixture
-def app_client():
- """Flask test client для app-autotest."""
+def app_client(polygon_server):
+ """Flask test client, направленный в локальный Polygon TEST."""
from app import app
+ old_polygon_endpoint = app.config.get("POLYGON_ENDPOINT", "")
+ app.config["POLYGON_ENDPOINT"] = f"{polygon_server}/api/v1/svc"
app.config.update(TESTING=True)
- with app.test_client() as client:
- yield client
+ try:
+ with app.test_client() as client:
+ yield client
+ finally:
+ app.config["POLYGON_ENDPOINT"] = old_polygon_endpoint
@pytest.fixture(scope="session")
@@ -37,18 +43,26 @@ def polygon_server():
if not POLYGON_DIR.is_dir():
pytest.skip("polygon repo not found")
+ with socket.socket() as port_socket:
+ port_socket.bind(("127.0.0.1", 0))
+ port = port_socket.getsockname()[1]
+ polygon_url = f"http://127.0.0.1:{port}"
+ env = os.environ.copy()
+ env["POLYGON_STANDS"] = "test:services/test"
+
proc = subprocess.Popen(
- ["python3", "app.py"],
+ ["python3", "-c", f"import app; app.app.run(host='127.0.0.1', port={port}, debug=False)"],
cwd=str(POLYGON_DIR),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
+ env=env,
)
# Ждём готовности (poll /health, timeout 10s)
deadline = time.time() + 10
while time.time() < deadline:
try:
- r = requests.get(f"{POLYGON_URL}/health", timeout=2)
+ r = requests.get(f"{polygon_url}/health", timeout=2)
if r.status_code == 200:
break
except requests.ConnectionError:
@@ -58,7 +72,7 @@ def polygon_server():
proc.wait()
pytest.fail("polygon did not start within 10s")
- yield POLYGON_URL
+ yield polygon_url
proc.terminate()
proc.wait()
@@ -67,5 +81,5 @@ def polygon_server():
@pytest.fixture(autouse=True)
def reset_polygon(polygon_server):
"""Перед каждым интеграционным тестом — сброс состояния polygon."""
- requests.post(f"{polygon_server}/api/v1/svc/_mock/reset", timeout=5)
+ requests.post(f"{polygon_server}/test/api/v1/svc/_mock/reset", timeout=5)
diff --git a/tests/test_scenario_full.py b/tests/test_scenario_full.py
index a777e62..476b132 100644
--- a/tests/test_scenario_full.py
+++ b/tests/test_scenario_full.py
@@ -5,6 +5,7 @@
"""
import json
import time
+import pytest
# ── Хелперы ──
@@ -48,6 +49,46 @@ class TestScenarioLifecycle:
data = resp.get_json()
assert isinstance(data, list)
+ def test_manual_create_delete_uses_local_polygon(self, app_client, monkeypatch):
+ """Ручной API приложения проводит create и delete через локальный Polygon."""
+ from api.http_client import HttpClient
+ from routes import api_test as api_test_routes
+
+ monkeypatch.setattr(api_test_routes, "_finish_op", lambda *args, **kwargs: None)
+ monkeypatch.setattr(
+ HttpClient,
+ "raw_delete",
+ lambda *args, **kwargs: pytest.fail("Polygon delete must not call external CMDB"),
+ )
+
+ operations_response = app_client.get("/api/operations/1")
+ assert operations_response.status_code == 200
+ operations = operations_response.get_json()["operations"]
+ create_id = next(op["svcOperationId"] for op in operations if op["operation"] == "create")
+ delete_id = next(op["svcOperationId"] for op in operations if op["operation"] == "delete")
+
+ created = app_client.post("/api/test", json={
+ "serviceId": 1,
+ "operation": "create",
+ "svcOperationId": create_id,
+ "params": {},
+ "displayName": "autotest-local-integration",
+ })
+ assert created.status_code == 200, created.get_json()
+ create_result = app_client.get(f"/api/test/status/{created.get_json()['opUid']}").get_json()
+ assert create_result["status"] == "OK"
+
+ deleted = app_client.post("/api/test", json={
+ "serviceId": 1,
+ "operation": "delete",
+ "svcOperationId": delete_id,
+ "instanceUid": created.get_json()["instanceUid"],
+ "params": {},
+ })
+ assert deleted.status_code == 200, deleted.get_json()
+ delete_result = app_client.get(f"/api/test/status/{deleted.get_json()['opUid']}").get_json()
+ assert delete_result["status"] == "OK"
+
def test_create_and_run_simple_scenario(self, app_client):
"""Сценарий create -> delete. Проверяем что оба шага OK."""
steps = [
diff --git a/tests/test_smoke_all_services.py b/tests/test_smoke_all_services.py
index 1793801..4be66e8 100644
--- a/tests/test_smoke_all_services.py
+++ b/tests/test_smoke_all_services.py
@@ -4,7 +4,9 @@
Бьёт в РАЗВЁРНУТЫЙ автотест (atest.pythonk8s.dev.nubes.ru), эмуляция TEST.
Операции идут через app-autotest -> executor -> save_run -> история в БД.
"""
-import time, requests
+import time, requests, pytest
+
+pytestmark = pytest.mark.skip(reason="live test against deployed atest service; run this file directly")
BASE = "https://atest.pythonk8s.dev.nubes.ru"
COOKIES = {"mode": "polygon", "polygon_stand": "test"}
@@ -19,8 +21,13 @@ def test_services():
def test_create(svc_id):
+ operations_response = requests.get(f"{BASE}/api/operations/{svc_id}", cookies=COOKIES, timeout=10)
+ assert operations_response.status_code == 200
+ operations = operations_response.json().get("operations", [])
+ create_op = next((op for op in operations if op.get("operation") == "create"), None)
+ assert create_op is not None, f"No create operation for service {svc_id}"
r = requests.post(f"{BASE}/api/test",
- json={"serviceId": svc_id, "operation": "create", "svcOperationId": 18,
+ json={"serviceId": svc_id, "operation": "create", "svcOperationId": create_op["svcOperationId"],
"params": {}, "displayName": f"autotest-smoke-{svc_id}"},
cookies=COOKIES, timeout=30)
assert r.status_code == 200, f"Create failed: {r.text[:200]}"
diff --git a/tests/test_static_regressions.py b/tests/test_static_regressions.py
index e9bb900..3685312 100644
--- a/tests/test_static_regressions.py
+++ b/tests/test_static_regressions.py
@@ -21,3 +21,44 @@ def test_scenario_name_escape_chain_includes_amp_backslash_quote_doublequote():
".replace(/\"/g,'"');"
)
assert expected in content
+
+
+def test_scenario_editor_loads_operations_from_selected_service():
+ js_path = Path(__file__).resolve().parents[1] / "site" / "static" / "js" / "scenario-form.js"
+ content = js_path.read_text(encoding="utf-8")
+
+ assert "async function loadServiceOperations(svcId)" in content
+ assert "fetch('/api/operations/' + svcId)" in content
+ assert "serviceOps.filter(item => item.operation !== 'reconcile')" in content
+ assert "const allOps = ['create', 'delete', 'modify', 'suspend', 'resume', 'redeploy']" not in content
+
+
+def test_manual_create_uses_current_service_operation_id():
+ js_path = Path(__file__).resolve().parents[1] / "site" / "static" / "js" / "operations.js"
+ content = js_path.read_text(encoding="utf-8")
+
+ assert "find(o => o.operation === 'create')" in content
+ assert "showParams(createOp.svcOperationId, 'create')" in content
+ assert "showParams(18, 'create')" not in content
+
+
+def test_scenario_editor_preserves_legacy_and_cloud_instance_references():
+ js_path = Path(__file__).resolve().parents[1] / "site" / "static" / "js" / "scenario-form.js"
+ content = js_path.read_text(encoding="utf-8")
+
+ assert "_legacyInstanceFallback: s.operation !== 'create'" in content
+ assert "_showCloud: Boolean(s.instance_uid)" in content
+ assert "ref === step.instance_uid" in content
+ assert "s.instance_uid = ref" in content
+ assert "&& !scenarioEditorState.steps[i]._legacyInstanceFallback" in content
+
+
+def test_live_scripts_are_not_collected_as_network_tests():
+ tests_dir = Path(__file__).resolve().parent
+ user_simulation = (tests_dir / "test_user_simulation.py").read_text(encoding="utf-8")
+ smoke = (tests_dir / "test_smoke_all_services.py").read_text(encoding="utf-8")
+
+ assert "pytest.skip(" in user_simulation
+ assert "pytestmark = pytest.mark.skip" in smoke
+ assert "operation_id(sid, \"create\"" in user_simulation
+ assert "create_op[\"svcOperationId\"]" in smoke
diff --git a/tests/test_user_simulation.py b/tests/test_user_simulation.py
index 80f1a1a..a3eb52b 100644
--- a/tests/test_user_simulation.py
+++ b/tests/test_user_simulation.py
@@ -4,7 +4,10 @@
ручные операции + сценарии + переключения + краевые случаи.
Запуск: python3 tests/test_user_simulation.py
"""
-import time, requests, json, sys
+import time, requests, json, sys, pytest
+
+if __name__ != "__main__":
+ pytest.skip("live test against deployed atest service; run this file directly", allow_module_level=True)
BASE = "https://atest.pythonk8s.dev.nubes.ru"
PASS = FAIL = 0
@@ -31,6 +34,14 @@ def post(path, data, cookies=None, code=200):
assert r.status_code == code, f"POST {path}: {r.status_code} {r.text[:100]}"
return r.json() if r.text.strip() else {}
+def operation_id(service_id, operation, cookies=None):
+ operations = get(f"/api/operations/{service_id}", cookies)
+ match = next((item for item in operations.get("operations", [])
+ if item.get("operation") == operation), None)
+ if not match:
+ raise RuntimeError(f"Operation {operation!r} not found for service {service_id}")
+ return match["svcOperationId"]
+
def poll(op_uid, cookies=None, timeout=15):
deadline = time.time() + timeout
while time.time() < deadline:
@@ -42,7 +53,7 @@ def poll(op_uid, cookies=None, timeout=15):
def create_instance(sid, name=None, cookies=None):
r = post("/api/test",
- {"serviceId": sid, "operation": "create", "svcOperationId": 18,
+ {"serviceId": sid, "operation": "create", "svcOperationId": operation_id(sid, "create", cookies),
"params": {}, "displayName": name or f"autotest-user-{sid}"},
cookies)
return r["opUid"], r.get("instanceUid")
@@ -249,7 +260,7 @@ try:
# 9.1 Создать PostgreSQL
pg_name = f"autotest-pg-{int(time.time())}"
r = post("/api/test",
- {"serviceId": 90, "operation": "create", "svcOperationId": 19,
+ {"serviceId": 90, "operation": "create", "svcOperationId": operation_id(90, "create"),
"params": {}, "displayName": pg_name})
pg_op = r["opUid"]
pg_uid = r.get("instanceUid")
@@ -260,7 +271,7 @@ try:
# 9.2 Создать пользователя
user_name = f"testuser_{int(time.time()) % 10000}"
r = post("/api/test",
- {"serviceId": 90, "operation": "create_user", "svcOperationId": 241,
+ {"serviceId": 90, "operation": "create_user", "svcOperationId": operation_id(90, "create_user"),
"instanceUid": pg_uid,
"params": {"733": user_name, "740": "app_user", "1096": "false"}})
user_op = r["opUid"]
@@ -270,7 +281,7 @@ try:
# 9.3 Создать базу данных
db_name = f"testdb_{int(time.time()) % 10000}"
r = post("/api/test",
- {"serviceId": 90, "operation": "create_database", "svcOperationId": 245,
+ {"serviceId": 90, "operation": "create_database", "svcOperationId": operation_id(90, "create_database"),
"instanceUid": pg_uid,
"params": {"741": db_name, "742": user_name}})
db_op = r["opUid"]
@@ -279,7 +290,7 @@ try:
# 9.4 Удалить PostgreSQL
r = post("/api/test",
- {"serviceId": 90, "operation": "delete", "svcOperationId": 20,
+ {"serviceId": 90, "operation": "delete", "svcOperationId": operation_id(90, "delete"),
"instanceUid": pg_uid, "params": {}})
del_op = r["opUid"]
result = poll(del_op)