diff --git a/site/app.py b/site/app.py index 99775ec..4bf0dec 100644 --- a/site/app.py +++ b/site/app.py @@ -32,7 +32,7 @@ from routes.api_scenario_defs import bp_defs as api_scenario_defs_bp # Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода. # Нужна для фильтрации истории (пользователь видит только записи своей версии). -VERSION = "1.2.47" +VERSION = "1.2.48" # Flask-приложение с Jinja2-шаблонами из папки templates/ app = Flask(__name__, template_folder="templates", static_folder="static") diff --git a/site/config/services_dev.txt b/site/config/services_dev.txt index a42a459..3036a4f 100644 --- a/site/config/services_dev.txt +++ b/site/config/services_dev.txt @@ -13,7 +13,7 @@ # 24 vcNat # DEPRECATED Правила маршрутизации для VM (vc_nat) 25 vcexternalip # Публичные IP адреса 26 vapp # Виртуальный каталог ВМ (vApp) -27 vc_vm_v2 # VM в Cloud Director (vc_vmV2) +# 27 vc_vm_v2 # отсутствует в текущем Polygon DEV 28 vc_vm_v3 # Виртуальная машина 29 vcVdcGroup # Группа датацентров # 32 vmpostgre # vc_vm_postgresql_std diff --git a/site/config/services_test.txt b/site/config/services_test.txt index a42a459..6d68d2e 100644 --- a/site/config/services_test.txt +++ b/site/config/services_test.txt @@ -13,7 +13,7 @@ # 24 vcNat # DEPRECATED Правила маршрутизации для VM (vc_nat) 25 vcexternalip # Публичные IP адреса 26 vapp # Виртуальный каталог ВМ (vApp) -27 vc_vm_v2 # VM в Cloud Director (vc_vmV2) +# 27 vc_vm_v2 # отсутствует в текущем Polygon TEST 28 vc_vm_v3 # Виртуальная машина 29 vcVdcGroup # Группа датацентров # 32 vmpostgre # vc_vm_postgresql_std diff --git a/site/routes/api_test.py b/site/routes/api_test.py index 0963ecb..7da4cdc 100644 --- a/site/routes/api_test.py +++ b/site/routes/api_test.py @@ -31,7 +31,7 @@ import json import fcntl from api.http_client import HttpClient, detect_endpoint, stand_name -from api.auth import get_token, get_client, get_client_id, get_stand, get_token_info +from api.auth import get_token, get_client, get_client_id, get_stand, get_token_info, get_mode from api.utils import find_uid, uid_from_location from operations.get_services import get_services, get_service_detail from operations.get_instances import get_instances @@ -217,7 +217,7 @@ def api_test(): if not instance_uid: return jsonify({"status": "FAIL", "error": "Нет instanceUid"}), 400 - if op_name == "delete": + if op_name == "delete" and get_mode() == "cloud": # Жёсткое удаление через CMDB (без авторизации) — для недосозданных инстансов cmdb_url = f"https://cmdb-api.deck.nubes.ru/instances/{instance_uid}" cmdb_ok, cmdb_code = client.raw_delete(cmdb_url) diff --git a/site/routes/main.py b/site/routes/main.py index 49fd4a9..81c3f70 100644 --- a/site/routes/main.py +++ b/site/routes/main.py @@ -223,11 +223,11 @@ def api_operations(svc_id): # Сортировка: по имени сервиса, затем по дате создания (новые сверху) svc_instances.sort(key=lambda i: ( i.get("svc", ""), - i.get("instanceConfigDtCreated") or "" + i.get("instanceConfigDtCreated") or i.get("dtCreate") or "" ), reverse=True) # reverse=True даёт обратный порядок по обоим ключам. # Чтобы сервисы шли A→Я а даты новые→старые — пересортируем: - svc_instances.sort(key=lambda i: i.get("instanceConfigDtCreated") or "", reverse=True) + svc_instances.sort(key=lambda i: i.get("instanceConfigDtCreated") or i.get("dtCreate") or "", reverse=True) svc_instances.sort(key=lambda i: i.get("svc", "")) # --- Шаг 2: Tracker-fallback --- diff --git a/site/static/js/instances.js b/site/static/js/instances.js index a68df96..b5b8b97 100644 --- a/site/static/js/instances.js +++ b/site/static/js/instances.js @@ -16,12 +16,12 @@ // busy — флаг блокировки UI (пока идёт операция) // AUTOTEST_PREFIX — "autotest-" (фильтр "наших" инстансов) -let svcInstances=[], selectedInst=null, selectedOp=null; -let currentSvcId=(window.APP&&window.APP.firstServiceId)||1; -let currentSvcName=''; -let currentSvcShort=''; -let busy=false; -const AUTOTEST_PREFIX='autotest-'; +let svcInstances = [], selectedInst = null, selectedOp = null; +let currentSvcId = (window.APP && window.APP.firstServiceId) || 1; +let currentSvcName = ''; +let currentSvcShort = ''; +let busy = false; +const AUTOTEST_PREFIX = 'autotest-'; /** * Выбрать сервис — загрузить его инстансы и показать в списке. @@ -29,37 +29,37 @@ const AUTOTEST_PREFIX='autotest-'; * * @param {number} svcId — ID сервиса */ -async function selectService(svcId){ - if(busy) return; // блокировка — идёт операция +async function selectService(svcId) { + if (busy) return; // блокировка — идёт операция // Сброс предыдущего состояния - currentSvcId=svcId; - selectedInst=null; - selectedOp=null; + currentSvcId = svcId; + selectedInst = null; + selectedOp = null; stopPoll(); // остановить поллинг предыдущей операции // Скрыть панель параметров и этапы - document.getElementById('params-area').style.display='none'; - document.querySelectorAll('.stages-box').forEach(e=>e.remove()); + document.getElementById('params-area').style.display = 'none'; + document.querySelectorAll('.stages-box').forEach(e => e.remove()); // Подсветка активного сервиса в списке - document.querySelectorAll('.svc-item').forEach(e=>e.classList.remove('active')); - const svcEl=document.querySelector(`.svc-item[onclick*="${svcId}"]`); - if(svcEl) svcEl.classList.add('active'); + document.querySelectorAll('.svc-item').forEach(e => e.classList.remove('active')); + const svcEl = document.querySelector(`.svc-item[onclick*="${svcId}"]`); + if (svcEl) svcEl.classList.add('active'); - try{ + try { // GET /api/operations/{svcId} — возвращает операции + autotest-инстансы - const r=await fetch('/api/operations/'+svcId); - const d=await r.json(); - svcInstances=d.instances||[]; - currentSvcName=d.svc||''; - currentSvcShort=d.svcShort||''; + const r = await fetch('/api/operations/' + svcId); + const d = await r.json(); + svcInstances = d.instances || []; + currentSvcName = d.svc || ''; + currentSvcShort = d.svcShort || ''; // Обновить текст кнопки «Создать» - const btnSpan=document.querySelector('#create-btn-area span'); - if(btnSpan) btnSpan.textContent='+ Создать новый инстанс '+(currentSvcName||'сервиса'); + const btnSpan = document.querySelector('#create-btn-area span'); + if (btnSpan) btnSpan.textContent = '+ Создать новый инстанс ' + (currentSvcName || 'сервиса'); renderInstances(); // отрисовать список - }catch(e){ - document.getElementById('inst-list').innerHTML= + } catch (e) { + document.getElementById('inst-list').innerHTML = 'Ошибка загрузки'; } } @@ -68,37 +68,38 @@ async function selectService(svcId){ * Отрисовать список инстансов в #inst-list. * Каждый инстанс — строка: имя + сервис + статус + created + modified. */ -function renderInstances(){ - let html=''; - svcInstances.forEach(i=>{ +function renderInstances() { + let html = ''; + svcInstances.forEach(i => { // Статус: из нашего поля status (cloud-first) или explainedStatus - const status=i.status||i.explainedStatus||'?'; - const sc=status==='running'?'badge-success':''; + const status = i.status || i.explainedStatus || '?'; + const sc = (status === 'running' || status === 'Выполнен') ? 'badge-success' : ''; + const created = i.instanceConfigDtCreated || i.dtCreate || ''; // Строка инстанса — кликабельна - html+=`
+ html += `
${_esc(i.displayName)} - ${_esc(i.svc||'')} + ${_esc(i.svc || '')} ${_esc(status)} - ${relativeTime(i.instanceConfigDtCreated)} - ${relativeTime(i.instanceConfigDtModified)} + ${relativeTime(created)} + ${relativeTime(i.instanceConfigDtModified)}
`; // Контейнер для кнопок операций (изначально скрыт) - html+=`
`; + html += `
`; }); - document.getElementById('inst-list').innerHTML=html; + document.getElementById('inst-list').innerHTML = html; } /** * Обновить список инстансов (без смены сервиса). * Вызывается после завершения операции. */ -async function refreshInstances(){ - try{ - const r=await fetch('/api/operations/'+currentSvcId); - const d=await r.json(); - svcInstances=d.instances||[]; +async function refreshInstances() { + try { + const r = await fetch('/api/operations/' + currentSvcId); + const d = await r.json(); + svcInstances = d.instances || []; renderInstances(); - }catch(e){} + } catch (e) { } } /** @@ -107,52 +108,52 @@ async function refreshInstances(){ * * @param {string} iuid — instanceUid */ -async function toggleInstance(iuid){ - if(busy) return; - selectedInst=iuid; +async function toggleInstance(iuid) { + if (busy) return; + selectedInst = iuid; stopPoll(); // Подсветка выбранного инстанса - document.querySelectorAll('[data-iuid]').forEach(e=> - e.classList.toggle('active',e.dataset.iuid===iuid)); + document.querySelectorAll('[data-iuid]').forEach(e => + e.classList.toggle('active', e.dataset.iuid === iuid)); // Скрыть панель параметров (если была открыта для другого инстанса) - document.getElementById('params-area').style.display='none'; + document.getElementById('params-area').style.display = 'none'; - const opsEl=document.getElementById('ops-'+iuid); + const opsEl = document.getElementById('ops-' + iuid); // Если уже открыт — закрыть (toggle) - if(opsEl.classList.contains('open')){opsEl.classList.remove('open');return;} + if (opsEl.classList.contains('open')) { opsEl.classList.remove('open'); return; } // Закрыть ВСЕ другие открытые панели операций - document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open')); + document.querySelectorAll('.inst-ops').forEach(e => e.classList.remove('open')); // Найти инстанс в списке (может быть из другого сервиса) - const inst=svcInstances.find(x=>x.instanceUid===iuid); - const svcId=inst?inst.serviceId:currentSvcId; + const inst = svcInstances.find(x => x.instanceUid === iuid); + const svcId = inst ? inst.serviceId : currentSvcId; - try{ - const r=await fetch('/api/operations/'+svcId); - const d=await r.json(); + try { + const r = await fetch('/api/operations/' + svcId); + const d = await r.json(); // Фильтруем операции: убираем reconcile (техническая) и create (уже создан) - let ops=(d.operations||[]).filter(o=>o.operation!=='reconcile'&&o.operation!=='create'); + let ops = (d.operations || []).filter(o => o.operation !== 'reconcile' && o.operation !== 'create'); // Для not created инстансов — только delete - if(inst&&(inst.status||inst.explainedStatus)==='not created'){ - ops=ops.filter(o=>o.operation==='delete'); + if (inst && (inst.status || inst.explainedStatus) === 'not created') { + ops = ops.filter(o => o.operation === 'delete'); } // Кнопки операций с цветовой маркировкой // o.operation в onclick — JS-escape: \ → \\, ' → \' // o.operation в тексте кнопки — HTML-escape через _esc() - opsEl.innerHTML=ops.map(o=>{ + opsEl.innerHTML = ops.map(o => { const escOp = _esc(o.operation); - const jsOp = o.operation.replace(/\\/g,'\\\\').replace(/'/g,"\\'"); + const jsOp = o.operation.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); return ``; }).join(''); opsEl.classList.add('open'); - }catch(e){ - opsEl.innerHTML='Ошибка загрузки'; + } catch (e) { + opsEl.innerHTML = 'Ошибка загрузки'; opsEl.classList.add('open'); } } @@ -163,12 +164,12 @@ async function toggleInstance(iuid){ * @param {string} opName — "modify"/"delete"/"suspend"/... * @returns {string} CSS-класс */ -function opClass(opName){ - if(opName==='modify') return 'op-btn-modify'; // синий - if(opName==='suspend') return 'op-btn-suspend'; // оранжевый - if(opName==='resume') return 'op-btn-resume'; // зелёный - if(opName==='delete') return 'op-btn-delete'; // красный - if(opName==='redeploy') return 'op-btn-redeploy'; // фиолетовый +function opClass(opName) { + if (opName === 'modify') return 'op-btn-modify'; // синий + if (opName === 'suspend') return 'op-btn-suspend'; // оранжевый + if (opName === 'resume') return 'op-btn-resume'; // зелёный + if (opName === 'delete') return 'op-btn-delete'; // красный + if (opName === 'redeploy') return 'op-btn-redeploy'; // фиолетовый return ''; } diff --git a/site/static/js/operations.js b/site/static/js/operations.js index 014b5f6..5148a0b 100644 --- a/site/static/js/operations.js +++ b/site/static/js/operations.js @@ -12,7 +12,7 @@ // Глобальные переменные (используются из instances.js): // pollTimer — ID setInterval для поллинга -let pollTimer=null; +let pollTimer = null; /** * Сгенерировать короткое имя для нового инстанса. @@ -21,9 +21,9 @@ let pollTimer=null; * * @returns {string} displayName suffix (без префикса autotest-) */ -function makeCreateDisplayName(){ +function makeCreateDisplayName() { const rand = (Math.random() * 46656 | 0).toString(36).padStart(3, '0'); - return currentSvcShort ? currentSvcShort+'-'+rand : rand; + return currentSvcShort ? currentSvcShort + '-' + rand : rand; } /** @@ -31,39 +31,45 @@ function makeCreateDisplayName(){ * * @returns {string} displayName или instanceUid (если не найден) */ -function findInstName(){ - const i=svcInstances.find(x=>x.instanceUid===selectedInst); - return i?i.displayName:selectedInst; +function findInstName() { + const i = svcInstances.find(x => x.instanceUid === selectedInst); + return i ? i.displayName : selectedInst; } /** * Остановить поллинг текущей операции. */ -function stopPoll(){ - if(pollTimer){clearInterval(pollTimer);pollTimer=null;} +function stopPoll() { + if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } } /** * Начать создание нового инстанса (кнопка «+ Создать»). * * Загружает операцию create для текущего сервиса через GET /api/operations/{svcId}. - * Если операция create не найдена — fallback на svcOperationId=18 (стандартный). + * Числовой ID не зашивается в UI: он берётся из каталога выбранного сервиса. */ -async function startCreate(){ - if(busy) return; - selectedInst=null; // create — нет выбранного инстанса +async function startCreate() { + if (busy) return; + selectedInst = null; // create — нет выбранного инстанса stopPoll(); // Снять выделение со всех инстансов - document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.remove('active')); - document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open')); - try{ - const r=await fetch('/api/operations/'+currentSvcId); - const d=await r.json(); - const createOp=d.operations.find(o=>o.operation==='create'); - const opId=createOp?createOp.svcOperationId:18; // fallback - showParams(opId,'create'); - }catch(e){ - showParams(18,'create'); // fallback при ошибке + document.querySelectorAll('[data-iuid]').forEach(e => e.classList.remove('active')); + document.querySelectorAll('.inst-ops').forEach(e => e.classList.remove('open')); + try { + const r = await fetch('/api/operations/' + currentSvcId); + const d = await r.json(); + if (!r.ok || d.error) throw new Error(d.error || 'Не удалось загрузить операции сервиса'); + const createOp = (Array.isArray(d.operations) ? d.operations : []).find(o => o.operation === 'create'); + if (!createOp) { + document.getElementById('test-status').innerHTML = + 'У выбранного сервиса нет операции create'; + return; + } + showParams(createOp.svcOperationId, 'create'); + } catch (e) { + document.getElementById('test-status').innerHTML = + `Ошибка загрузки операций: ${_esc(e.message || '')}`; } } @@ -73,10 +79,10 @@ async function startCreate(){ * @param {string} opName — "modify"/"delete"/"suspend"/"resume"/"redeploy" * @param {number} opId — svcOperationId */ -function runOp(opName,opId){ - if(busy) return; +function runOp(opName, opId) { + if (busy) return; stopPoll(); - showParams(opId,opName); + showParams(opId, opName); } /** @@ -92,81 +98,81 @@ function runOp(opName,opId){ * @param {number} opId — svcOperationId * @param {string} opName — "create"/"modify"/... */ -function showParams(opId,opName){ +function showParams(opId, opName) { // Сохраняем выбранную операцию - selectedOp={opId,opName,svcId:currentSvcId}; + selectedOp = { opId, opName, svcId: currentSvcId }; // Показать панель параметров, скрыть старые этапы - document.getElementById('params-area').style.display='block'; - document.querySelectorAll('.stages-box').forEach(e=>e.remove()); - document.getElementById('test-status').textContent=''; + document.getElementById('params-area').style.display = 'block'; + document.querySelectorAll('.stages-box').forEach(e => e.remove()); + document.getElementById('test-status').textContent = ''; - const isCreate=opName==='create'; + const isCreate = opName === 'create'; // Для create — шаблонные значения, для не-create — с instanceUid - const qs=isCreate?'' : `?instanceUid=${selectedInst}`; + const qs = isCreate ? '' : `?instanceUid=${selectedInst}`; - fetch('/api/params/'+opId+qs).then(r=>r.json()).then(async data=>{ - if(data.error){ - document.getElementById('params-form').innerHTML= + fetch('/api/params/' + opId + qs).then(r => r.json()).then(async data => { + if (data.error) { + document.getElementById('params-form').innerHTML = `Ошибка: ${_esc(data.error)}`; return; } - const params=data.params||data; - if(!Array.isArray(params)){ - document.getElementById('params-form').innerHTML= + const params = data.params || data; + if (!Array.isArray(params)) { + document.getElementById('params-form').innerHTML = `Неверный формат параметров`; return; } // Если есть refSvcId-параметры — загружаем ВСЕ инстансы (для select'ов) - let allInst=[]; - const needRefs=params.some(p=>p.refSvcId); - if(needRefs){ - try{ - const r=await fetch('/api/instances/list'); - const list=await r.json(); - allInst=list||[]; - }catch(e){} + let allInst = []; + const needRefs = params.some(p => p.refSvcId); + if (needRefs) { + try { + const r = await fetch('/api/instances/list'); + const list = await r.json(); + allInst = list || []; + } catch (e) { } } - const form=document.getElementById('params-form'); - const displayNameValue=isCreate?makeCreateDisplayName():''; + const form = document.getElementById('params-form'); + const displayNameValue = isCreate ? makeCreateDisplayName() : ''; // Заголовок формы - const createHeader=isCreate + const createHeader = isCreate ? `
Создание: ${_esc(currentSvcName)}
` : ''; - const opHeader=!isCreate + const opHeader = !isCreate ? `
${_esc(opName)} → ${_esc(findInstName())}
` : ''; // Форма: заголовок + displayName (только create) + параметры - form.innerHTML=createHeader+opHeader - + (opName==='create' + form.innerHTML = createHeader + opHeader + + (opName === 'create' ? `
${AUTOTEST_PREFIX}
` : '') - + params.map(p=>renderParamRow(p,allInst)).join(''); + + params.map(p => renderParamRow(p, allInst)).join(''); // Кнопка «Запустить» - const btn=document.getElementById('btn-test'); - btn.style.display='inline-flex'; - btn.textContent='Запустить'; - btn.onclick=()=>{ + const btn = document.getElementById('btn-test'); + btn.style.display = 'inline-flex'; + btn.textContent = 'Запустить'; + btn.onclick = () => { // Валидация JSON-полей перед отправкой - let bad=[]; - document.querySelectorAll('#params-form [data-type="map"]').forEach(el=>{ - if(!validateJson(el,true)) bad.push(el); + let bad = []; + document.querySelectorAll('#params-form [data-type="map"]').forEach(el => { + if (!validateJson(el, true)) bad.push(el); }); - if(bad.length>0){ - document.getElementById('test-status').innerHTML= + if (bad.length > 0) { + document.getElementById('test-status').innerHTML = `Ошибка ${bad.length} полей с невалидным JSON — поправьте и нажмите Запустить снова`; return; } - const pp=collectParams(); // собрать значения из формы + const pp = collectParams(); // собрать значения из формы executeOp(pp); // отправить }; - }).catch(e=>{ - document.getElementById('params-form').innerHTML= + }).catch(e => { + document.getElementById('params-form').innerHTML = `Ошибка загрузки: ${_esc(e.message)}`; }); } @@ -180,29 +186,29 @@ function showParams(opId,opName){ * @param {number} statusDuration— длительность в секундах * @param {string} instanceName — displayName инстанса */ -function setFinishedState(statusText, statusClass, statusError, statusDuration, instanceName){ - busy=false; // разблокировать UI +function setFinishedState(statusText, statusClass, statusError, statusDuration, instanceName) { + busy = false; // разблокировать UI // Закрыть панели операций - document.querySelectorAll('.inst-ops.open').forEach(e=>e.classList.remove('open')); - const btn=document.getElementById('btn-test'); - btn.disabled=false; - btn.textContent='Готово'; - btn.onclick=null; // убрать обработчик - const opName=selectedOp?.opName||'операция'; - const durationText=statusDuration!==undefined&&statusDuration!==null?`${statusDuration}s`:'0s'; - const extra=_esc(statusError||''); - const instName=_esc(instanceName||findInstName()||selectedInst||''); - document.getElementById('test-status').innerHTML= + document.querySelectorAll('.inst-ops.open').forEach(e => e.classList.remove('open')); + const btn = document.getElementById('btn-test'); + btn.disabled = false; + btn.textContent = 'Готово'; + btn.onclick = null; // убрать обработчик + const opName = selectedOp?.opName || 'операция'; + const durationText = statusDuration !== undefined && statusDuration !== null ? `${statusDuration}s` : '0s'; + const extra = _esc(statusError || ''); + const instName = _esc(instanceName || findInstName() || selectedInst || ''); + document.getElementById('test-status').innerHTML = ` ${statusText} ${opName} ${durationText} ${instName} ${extra}`; } /** * Установить состояние «выполняется». */ -function setRunningState(opName, displayName){ - const btn=document.getElementById('btn-test'); - btn.textContent=opName+'...'; - document.getElementById('test-status').textContent=' ⏳ '+ (displayName||opName) +' выполняется...'; +function setRunningState(opName, displayName) { + const btn = document.getElementById('btn-test'); + btn.textContent = opName + '...'; + document.getElementById('test-status').textContent = ' ⏳ ' + (displayName || opName) + ' выполняется...'; } /** @@ -218,106 +224,106 @@ function setRunningState(opName, displayName){ * * @param {Object} params — {numeric_param_id: value} */ -async function executeOp(params){ - busy=true; // заблокировать UI +async function executeOp(params) { + busy = true; // заблокировать UI stopPoll(); - const isCreate=selectedOp?.opName==='create'; - const displayNameInput=document.getElementById('param-displayname'); - const displayNameSuffix=(displayNameInput?.value||'').trim(); - const displayName=isCreate + const isCreate = selectedOp?.opName === 'create'; + const displayNameInput = document.getElementById('param-displayname'); + const displayNameSuffix = (displayNameInput?.value || '').trim(); + const displayName = isCreate ? `${AUTOTEST_PREFIX}${displayNameSuffix || makeCreateDisplayName()}` : findInstName(); // Подготовка UI - document.getElementById('params-area').style.display='block'; - document.getElementById('params-form').innerHTML=''; // очистить форму - const btn=document.getElementById('btn-test'); - btn.style.display='inline-flex'; - btn.textContent=selectedOp.opName+'...'; - btn.disabled=true; + document.getElementById('params-area').style.display = 'block'; + document.getElementById('params-form').innerHTML = ''; // очистить форму + const btn = document.getElementById('btn-test'); + btn.style.display = 'inline-flex'; + btn.textContent = selectedOp.opName + '...'; + btn.disabled = true; setRunningState(selectedOp.opName, displayName); // Создать контейнер для этапов (после кнопки create или после панели операций) - const instId=selectedInst||''; - const afterEl=instId?document.getElementById('ops-'+instId):null; - document.querySelectorAll('.stages-box').forEach(e=>e.remove()); - const stagesBox=document.createElement('div'); - stagesBox.className='stages-box'; - stagesBox.style.cssText='max-height:200px;overflow-y:auto;padding:4px 8px;margin-left:16px;background:var(--brand-grey-light);border-radius:4px;'; - if(afterEl){afterEl.after(stagesBox);} - else{document.getElementById('create-btn-area').after(stagesBox);} + const instId = selectedInst || ''; + const afterEl = instId ? document.getElementById('ops-' + instId) : null; + document.querySelectorAll('.stages-box').forEach(e => e.remove()); + const stagesBox = document.createElement('div'); + stagesBox.className = 'stages-box'; + stagesBox.style.cssText = 'max-height:200px;overflow-y:auto;padding:4px 8px;margin-left:16px;background:var(--brand-grey-light);border-radius:4px;'; + if (afterEl) { afterEl.after(stagesBox); } + else { document.getElementById('create-btn-area').after(stagesBox); } - try{ + try { // POST /api/test — тело: serviceId, operation, svcOperationId, params, instanceUid, displayName - const r=await fetch('/api/test',{ - method:'POST', - headers:{'Content-Type':'application/json'}, - body:JSON.stringify({ - serviceId:currentSvcId, - operation:selectedOp.opName, - svcOperationId:selectedOp.opId, + const r = await fetch('/api/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + serviceId: currentSvcId, + operation: selectedOp.opName, + svcOperationId: selectedOp.opId, params, - instanceUid:selectedInst||'', - displayName:isCreate?displayName:'' + instanceUid: selectedInst || '', + displayName: isCreate ? displayName : '' }) }); - const d=await r.json(); + const d = await r.json(); // FAIL на старте — нечего поллить - if(d.status==='FAIL'){ - busy=false; - document.getElementById('test-status').innerHTML= - ` FAIL ${_esc(d.error||'')}`; - btn.disabled=false; + if (d.status === 'FAIL') { + busy = false; + document.getElementById('test-status').innerHTML = + ` FAIL ${_esc(d.error || '')}`; + btn.disabled = false; return; } // CMDB-delete (opUid начинается с "cmdb-") — мгновенный успех - if(d.status==='OK'&&(d.opUid||'').startsWith('cmdb-')){ - busy=false; - setFinishedState('OK','badge-success','',0,d.displayName); - try{await refreshInstances();}catch(e){} - if(historyOpen) try{await loadHistory();}catch(e){} + if (d.status === 'OK' && (d.opUid || '').startsWith('cmdb-')) { + busy = false; + setFinishedState('OK', 'badge-success', '', 0, d.displayName); + try { await refreshInstances(); } catch (e) { } + if (historyOpen) try { await loadHistory(); } catch (e) { } return; } - const opUid=d.opUid; - const instanceName=d.displayName||displayName; + const opUid = d.opUid; + const instanceName = d.displayName || displayName; // Поллинг: каждые 2 секунды - let _pollErrors=0; - pollTimer=setInterval(async()=>{ - try{ - const sr=await fetch('/api/test/status/'+opUid); - const sd=await sr.json(); - _pollErrors=0; // сброс счётчика ошибок - showStages(sd.stages||[]); // обновить этапы - if(sd.status!=='RUNNING'){ + let _pollErrors = 0; + pollTimer = setInterval(async () => { + try { + const sr = await fetch('/api/test/status/' + opUid); + const sd = await sr.json(); + _pollErrors = 0; // сброс счётчика ошибок + showStages(sd.stages || []); // обновить этапы + if (sd.status !== 'RUNNING') { stopPoll(); setFinishedState( sd.status, - sd.status==='OK'?'badge-success':'badge', - sd.error||sd.errorLog, + sd.status === 'OK' ? 'badge-success' : 'badge', + sd.error || sd.errorLog, sd.duration, - sd.displayName||instanceName + sd.displayName || instanceName ); await refreshInstances(); // обновить список инстансов - if(historyOpen) await loadHistory(); // обновить историю + if (historyOpen) await loadHistory(); // обновить историю } - }catch(e){ + } catch (e) { _pollErrors++; // 5 ошибок подряд (10 секунд) → считаем связь потерянной - if(_pollErrors>=5){ + if (_pollErrors >= 5) { stopPoll(); - setFinishedState('TIMEOUT','','Связь потеряна',0,instanceName); + setFinishedState('TIMEOUT', '', 'Связь потеряна', 0, instanceName); } } - },2000); - }catch(e){ - busy=false; - document.getElementById('test-status').textContent=' Ошибка: '+e.message; - btn.disabled=false; + }, 2000); + } catch (e) { + busy = false; + document.getElementById('test-status').textContent = ' Ошибка: ' + e.message; + btn.disabled = false; } } @@ -328,15 +334,15 @@ async function executeOp(params){ * * @param {Array} stages — [{stage, dtStart, dtFinish, isSuccessful, duration}, ...] */ -function showStages(stages){ - if(!stages||!stages.length) return; - let html='
Этапы
'; - stages.forEach(s=>{ - const done=!!s.dtFinish; // этап завершён? - const icon=done?(s.isSuccessful?'✅':'❌'):'⏳'; - html+=`
${icon} ${_esc(s.stage)} — ${(s.duration||0).toFixed(1)}s
`; +function showStages(stages) { + if (!stages || !stages.length) return; + let html = '
Этапы
'; + stages.forEach(s => { + const done = !!s.dtFinish; // этап завершён? + const icon = done ? (s.isSuccessful ? '✅' : '❌') : '⏳'; + html += `
${icon} ${_esc(s.stage)} — ${(s.duration || 0).toFixed(1)}s
`; }); // Показать в ПОСЛЕДНЕМ контейнере этапов (самом новом) - 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)