feat(web-console): create/edit/delete functions in UI, v0.1.4
- services/funcs: новые маршруты POST create-function, POST save-function, DELETE delete-function — создание/редактирование/удаление через браузер - index.html: модалка создания с шаблонами Python/Node.js hello world, кнопки edit/delete на каждой карточке функции - sless-funcs-service:v0.1.4 задеплоен - examples/POSTGRES: удалены логи, .bak, backup tfstate, лишние функции оставлены 2 Python (pg-stats, pg-counter) + 2 Node.js (pg-info, js-idempotent)
This commit is contained in:
@@ -27,7 +27,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: funcs
|
||||
image: naeel/sless-funcs-service:v0.1.3
|
||||
image: naeel/sless-funcs-service:v0.1.4
|
||||
ports:
|
||||
- containerPort: 8090
|
||||
env:
|
||||
|
||||
+462
-2
@@ -312,6 +312,181 @@
|
||||
color: #484f58;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Кнопки действий на карточке функции */
|
||||
.action-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 3px 10px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #30363d;
|
||||
cursor: pointer;
|
||||
font-size: 0.73rem;
|
||||
font-weight: 500;
|
||||
background: #21262d;
|
||||
color: #8b949e;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.action-btn:hover { background: #30363d; color: #c9d1d9; }
|
||||
.action-btn.del:hover { background: #3d1a1a; border-color: #6b1a1a; color: #f85149; }
|
||||
|
||||
/* Кодовый редактор во встроенном режиме */
|
||||
.edit-area {
|
||||
width: 100%;
|
||||
min-height: 220px;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
border: none;
|
||||
border-top: 1px solid #30363d;
|
||||
padding: 14px 16px;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: #161b22;
|
||||
border-top: 1px solid #30363d;
|
||||
}
|
||||
.edit-save-btn {
|
||||
padding: 5px 16px;
|
||||
background: #238636;
|
||||
border: 1px solid #2ea043;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.edit-save-btn:hover { background: #2ea043; }
|
||||
.edit-save-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.edit-cancel-btn {
|
||||
padding: 5px 16px;
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
color: #c9d1d9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.edit-status {
|
||||
font-size: 0.77rem;
|
||||
padding: 5px 8px;
|
||||
align-self: center;
|
||||
color: #8b949e;
|
||||
}
|
||||
.edit-status.ok { color: #3fb950; }
|
||||
.edit-status.err { color: #f85149; }
|
||||
|
||||
/* ---------- Модалка создания функции ---------- */
|
||||
.modal-backdrop {
|
||||
display: none;
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.65);
|
||||
z-index: 100;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-backdrop.open { display: flex; }
|
||||
.modal {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 10px;
|
||||
width: min(660px, 95vw);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.modal h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #e6edf3;
|
||||
}
|
||||
.modal label {
|
||||
font-size: 0.82rem;
|
||||
color: #8b949e;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.modal input, .modal select {
|
||||
width: 100%;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
padding: 7px 10px;
|
||||
font-size: 0.85rem;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
.modal input:focus, .modal select:focus { border-color: #58a6ff; }
|
||||
.modal-row { display: flex; gap: 12px; }
|
||||
.modal-row > div { flex: 1; }
|
||||
.modal-code {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
padding: 10px 12px;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal-code:focus { border-color: #58a6ff; }
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.modal-create-btn {
|
||||
padding: 7px 20px;
|
||||
background: #238636;
|
||||
border: 1px solid #2ea043;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.modal-create-btn:hover { background: #2ea043; }
|
||||
.modal-create-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.modal-cancel-btn {
|
||||
padding: 7px 20px;
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
color: #c9d1d9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.modal-err {
|
||||
color: #f85149;
|
||||
font-size: 0.8rem;
|
||||
min-height: 18px;
|
||||
}
|
||||
.create-fn-btn {
|
||||
margin-left: auto;
|
||||
background: #238636;
|
||||
border: 1px solid #2ea043;
|
||||
color: #fff;
|
||||
padding: 6px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.create-fn-btn:hover { background: #2ea043; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -321,12 +496,62 @@
|
||||
<span class="logo-slash">/</span>
|
||||
<span class="ns-badge" id="ns-label">…</span>
|
||||
<button class="refresh-btn" onclick="location.reload()">↻ обновить</button>
|
||||
<button class="create-fn-btn" id="open-create-modal">+ Создать функцию</button>
|
||||
</header>
|
||||
<main>
|
||||
<p class="summary" id="summary"></p>
|
||||
<div id="fn-list"></div>
|
||||
</main>
|
||||
|
||||
<!-- Модалка создания новой функции -->
|
||||
<div class="modal-backdrop" id="create-modal">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<h2 id="modal-title">Создать функцию</h2>
|
||||
|
||||
<div>
|
||||
<label>Runtime</label>
|
||||
<select id="m-runtime">
|
||||
<option value="python3.11">Python 3.11</option>
|
||||
<option value="nodejs20">Node.js 20</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="modal-row">
|
||||
<div>
|
||||
<label>Имя функции (k8s name)</label>
|
||||
<input id="m-name" type="text" placeholder="my-hello-fn" pattern="[a-z0-9][a-z0-9\-]{0,61}[a-z0-9]">
|
||||
</div>
|
||||
<div>
|
||||
<label>Entrypoint (module.func)</label>
|
||||
<input id="m-entrypoint" type="text" value="handler.handler">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-row">
|
||||
<div>
|
||||
<label>Память (MB)</label>
|
||||
<input id="m-memory" type="number" value="128" min="64" max="4096">
|
||||
</div>
|
||||
<div>
|
||||
<label>Таймаут (сек)</label>
|
||||
<input id="m-timeout" type="number" value="30" min="1" max="900">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label id="m-filename-label">Файл: <code id="m-filename-display">handler.py</code></label>
|
||||
<textarea class="modal-code" id="m-code" spellcheck="false"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="modal-err" id="m-err"></div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel-btn" id="modal-cancel">Отмена</button>
|
||||
<button class="modal-create-btn" id="modal-submit">Создать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script id="fndata" type="application/json">__PAGE_DATA__</script>
|
||||
<script>
|
||||
(function () {
|
||||
@@ -355,6 +580,8 @@
|
||||
list.appendChild(buildCard(fns[i], byFn[fns[i].name] || []));
|
||||
}
|
||||
|
||||
// ---------- Карточки функций ----------
|
||||
|
||||
function buildCard(fn, triggers) {
|
||||
var card = $el('div', 'fn-card');
|
||||
var hdr = $el('div', 'fn-header');
|
||||
@@ -369,7 +596,6 @@
|
||||
$txt(hdr, 'span', 'badge badge-kind-' + fn.kind, kindLabel);
|
||||
}
|
||||
|
||||
// sless_service имеет прямой URL из статуса деплоя
|
||||
if (fn.url) {
|
||||
var a = document.createElement('a');
|
||||
a.className = 'fn-url';
|
||||
@@ -384,7 +610,6 @@
|
||||
for (var t = 0; t < triggers.length; t++) {
|
||||
var tr = triggers[t];
|
||||
if (tr.type === 'http') {
|
||||
// Для job-style функций URL берём из триггера; для сервисов уже показан fn.url
|
||||
if (!fn.url) {
|
||||
var url = extURL ? extURL + '/fn/' + ns + '/' + fn.name : (tr.url || '');
|
||||
if (url) {
|
||||
@@ -407,6 +632,43 @@
|
||||
|
||||
if (fn.message) $txt(hdr, 'span', 'fn-msg', fn.message);
|
||||
hdr.appendChild($el('span', 'spacer'));
|
||||
|
||||
// Кнопка редактирования кода
|
||||
var editBtn = $el('button', 'action-btn');
|
||||
editBtn.textContent = '✎';
|
||||
editBtn.title = 'Редактировать код';
|
||||
editBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
body.style.display = 'block';
|
||||
icon.style.transform = 'rotate(90deg)';
|
||||
openEditMode(body, ns, fn.name, fn.kind, fn.runtime);
|
||||
});
|
||||
hdr.appendChild(editBtn);
|
||||
|
||||
// Кнопка удаления
|
||||
var delBtn = $el('button', 'action-btn del');
|
||||
delBtn.textContent = '✕';
|
||||
delBtn.title = 'Удалить функцию';
|
||||
delBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (!confirm('Удалить функцию «' + fn.name + '»?\n\nЭто действие необратимо.')) return;
|
||||
delBtn.disabled = true;
|
||||
fetch('/funcs/' + ns + '/delete-function/' + fn.name + '?kind=' + (fn.kind || 'function'), {
|
||||
method: 'DELETE'
|
||||
}).then(function (r) {
|
||||
if (r.ok) {
|
||||
card.remove();
|
||||
} else {
|
||||
return r.text().then(function (t) { alert('Ошибка удаления: ' + t); });
|
||||
}
|
||||
}).catch(function (err) {
|
||||
alert('Сетевая ошибка: ' + err);
|
||||
}).finally(function () {
|
||||
delBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
hdr.appendChild(delBtn);
|
||||
|
||||
var icon = $txt(hdr, 'span', 'expand-icon', '▶');
|
||||
|
||||
var loaded = false;
|
||||
@@ -429,6 +691,95 @@
|
||||
return card;
|
||||
}
|
||||
|
||||
// openEditMode — открывает тело карточки в режиме редактирования.
|
||||
// Загружает текущий код, показывает textarea, кнопки Сохранить/Закрыть.
|
||||
function openEditMode(body, ns, fnName, fnKind, fnRuntime) {
|
||||
body.innerHTML = '';
|
||||
var msg = $el('div', 'src-msg');
|
||||
msg.textContent = 'Загрузка кода…';
|
||||
body.appendChild(msg);
|
||||
|
||||
var kindParam = fnKind === 'service' ? '?kind=service' : '';
|
||||
fetch('/funcs/' + ns + '/source/' + fnName + kindParam)
|
||||
.then(function (r) {
|
||||
body.removeChild(msg);
|
||||
if (!r.ok) {
|
||||
var e = $el('div', 'src-msg');
|
||||
e.textContent = 'Ошибка загрузки кода: ' + r.status;
|
||||
body.appendChild(e);
|
||||
return;
|
||||
}
|
||||
return r.json().then(function (files) {
|
||||
var editable = files.filter(function (f) {
|
||||
return !f.binary && (f.name.endsWith('.py') || f.name.endsWith('.js'));
|
||||
});
|
||||
if (editable.length === 0) {
|
||||
// Нет кода — показываем пустой редактор с шаблоном
|
||||
editable = [{ name: defaultFilename(fnRuntime), content: helloWorldTemplate(fnRuntime) }];
|
||||
}
|
||||
var f = editable[0];
|
||||
renderEditArea(body, ns, fnName, fnKind, f.name, f.content);
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
body.innerHTML = '';
|
||||
var e = $el('div', 'src-msg');
|
||||
e.textContent = 'Ошибка: ' + err;
|
||||
body.appendChild(e);
|
||||
});
|
||||
}
|
||||
|
||||
// renderEditArea — рисует textarea + кнопки Save/Cancel в теле карточки.
|
||||
function renderEditArea(body, ns, fnName, fnKind, filename, initialCode) {
|
||||
body.innerHTML = '';
|
||||
var ta = document.createElement('textarea');
|
||||
ta.className = 'edit-area';
|
||||
ta.spellcheck = false;
|
||||
ta.value = initialCode;
|
||||
body.appendChild(ta);
|
||||
|
||||
var actions = $el('div', 'edit-actions');
|
||||
var saveBtn = $el('button', 'edit-save-btn');
|
||||
saveBtn.textContent = 'Сохранить';
|
||||
var cancelBtn = $el('button', 'edit-cancel-btn');
|
||||
cancelBtn.textContent = 'Закрыть';
|
||||
var status = $el('span', 'edit-status');
|
||||
actions.appendChild(saveBtn);
|
||||
actions.appendChild(cancelBtn);
|
||||
actions.appendChild(status);
|
||||
body.appendChild(actions);
|
||||
|
||||
saveBtn.addEventListener('click', function () {
|
||||
saveBtn.disabled = true;
|
||||
status.textContent = 'Сохраняю…';
|
||||
status.className = 'edit-status';
|
||||
fetch('/funcs/' + ns + '/save-function/' + fnName, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: ta.value, filename: filename, kind: fnKind || 'function' })
|
||||
}).then(function (r) {
|
||||
if (r.ok) {
|
||||
status.textContent = '✓ Сохранено — сборка запущена';
|
||||
status.className = 'edit-status ok';
|
||||
} else {
|
||||
return r.text().then(function (t) {
|
||||
status.textContent = '✗ ' + t;
|
||||
status.className = 'edit-status err';
|
||||
});
|
||||
}
|
||||
}).catch(function (err) {
|
||||
status.textContent = '✗ ' + err;
|
||||
status.className = 'edit-status err';
|
||||
}).finally(function () {
|
||||
saveBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', function () {
|
||||
body.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function makeTriggerBtn(tr, ns) {
|
||||
var btn = $el('button', 'trigger-btn ' + (tr.enabled ? 'state-on' : 'state-off'));
|
||||
btn.textContent = tr.enabled ? '■ стоп' : '▶ запуск';
|
||||
@@ -506,6 +857,115 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Модалка создания функции ----------
|
||||
|
||||
var TEMPLATES = {
|
||||
'python3.11': {
|
||||
filename: 'handler.py',
|
||||
code: '# Hello World — Python 3.11\n# Entrypoint: handler.handler\n\ndef handler(event, context):\n name = event.get("name", "World")\n return {"result": f"Hello, {name}!"}\n'
|
||||
},
|
||||
'nodejs20': {
|
||||
filename: 'handler.js',
|
||||
code: '// Hello World — Node.js 20\n// Entrypoint: handler.handler\n\nexports.handler = async (event, context) => {\n const name = event.name || \'World\';\n return { result: `Hello, ${name}!` };\n};\n'
|
||||
}
|
||||
};
|
||||
|
||||
function defaultFilename(runtime) {
|
||||
return TEMPLATES[runtime] ? TEMPLATES[runtime].filename : 'handler.py';
|
||||
}
|
||||
function helloWorldTemplate(runtime) {
|
||||
return TEMPLATES[runtime] ? TEMPLATES[runtime].code : '# код функции\n';
|
||||
}
|
||||
|
||||
var modal = document.getElementById('create-modal');
|
||||
var mRuntime = document.getElementById('m-runtime');
|
||||
var mCode = document.getElementById('m-code');
|
||||
var mFilenameDisplay = document.getElementById('m-filename-display');
|
||||
var mErr = document.getElementById('m-err');
|
||||
|
||||
// Заполняем шаблон при открытии / смене runtime
|
||||
function applyTemplate() {
|
||||
var tpl = TEMPLATES[mRuntime.value];
|
||||
if (tpl) {
|
||||
mCode.value = tpl.code;
|
||||
mFilenameDisplay.textContent = tpl.filename;
|
||||
}
|
||||
}
|
||||
applyTemplate();
|
||||
mRuntime.addEventListener('change', applyTemplate);
|
||||
|
||||
document.getElementById('open-create-modal').addEventListener('click', function () {
|
||||
applyTemplate();
|
||||
mErr.textContent = '';
|
||||
document.getElementById('m-name').value = '';
|
||||
document.getElementById('m-entrypoint').value = 'handler.handler';
|
||||
document.getElementById('m-memory').value = '128';
|
||||
document.getElementById('m-timeout').value = '30';
|
||||
modal.classList.add('open');
|
||||
document.getElementById('m-name').focus();
|
||||
});
|
||||
|
||||
document.getElementById('modal-cancel').addEventListener('click', function () {
|
||||
modal.classList.remove('open');
|
||||
});
|
||||
modal.addEventListener('click', function (e) {
|
||||
if (e.target === modal) modal.classList.remove('open');
|
||||
});
|
||||
|
||||
document.getElementById('modal-submit').addEventListener('click', function () {
|
||||
var name = document.getElementById('m-name').value.trim();
|
||||
var runtime = mRuntime.value;
|
||||
var entrypoint = document.getElementById('m-entrypoint').value.trim();
|
||||
var memory = parseInt(document.getElementById('m-memory').value, 10);
|
||||
var timeout = parseInt(document.getElementById('m-timeout').value, 10);
|
||||
var code = mCode.value;
|
||||
var filename = TEMPLATES[runtime] ? TEMPLATES[runtime].filename : 'handler.py';
|
||||
|
||||
mErr.textContent = '';
|
||||
if (!name) { mErr.textContent = 'Укажите имя функции'; return; }
|
||||
if (!/^[a-z0-9][a-z0-9\-]{0,61}[a-z0-9]$/.test(name) && !/^[a-z0-9]$/.test(name)) {
|
||||
mErr.textContent = 'Имя должно быть в формате k8s: строчные буквы, цифры, дефисы';
|
||||
return;
|
||||
}
|
||||
if (!entrypoint) { mErr.textContent = 'Укажите entrypoint (например handler.handler)'; return; }
|
||||
if (!code.trim()) { mErr.textContent = 'Напишите код функции'; return; }
|
||||
|
||||
var btn = document.getElementById('modal-submit');
|
||||
btn.disabled = true;
|
||||
mErr.textContent = 'Создаю…';
|
||||
|
||||
fetch('/funcs/' + ns + '/create-function', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
runtime: runtime,
|
||||
code: code,
|
||||
filename: filename,
|
||||
entrypoint: entrypoint,
|
||||
memory_mb: memory,
|
||||
timeout_sec: timeout
|
||||
})
|
||||
}).then(function (r) {
|
||||
if (r.ok) {
|
||||
modal.classList.remove('open');
|
||||
mErr.textContent = '';
|
||||
// Перезагружаем страницу чтобы увидеть новую функцию
|
||||
setTimeout(function () { location.reload(); }, 400);
|
||||
} else {
|
||||
return r.text().then(function (t) {
|
||||
mErr.textContent = '✗ ' + t;
|
||||
});
|
||||
}
|
||||
}).catch(function (err) {
|
||||
mErr.textContent = '✗ Сетевая ошибка: ' + err;
|
||||
}).finally(function () {
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Утилиты ----------
|
||||
|
||||
function detectLang(name) {
|
||||
var ext = name.split('.').pop().toLowerCase();
|
||||
var m = {
|
||||
|
||||
+235
-7
@@ -1,15 +1,18 @@
|
||||
// Изменено: 2026-03-21 (добавлен вывод sless_service вместе с функциями — пользователь видит оба типа как «функции»)
|
||||
// Изменено: 2026-03-23 (добавлены эндпоинты создания/редактирования/удаления функций через UI)
|
||||
// main.go — глобальный HTTP сервис листинга функций пользователя.
|
||||
// Развёрнут ОДИН РАЗ в namespace sless; работает для ВСЕХ пользователей.
|
||||
// Не связан с terraform — деплоится манифестом deployments/k8s/funcs-service.yaml.
|
||||
//
|
||||
// Маршруты:
|
||||
// GET /funcs — usage hint (без токена)
|
||||
// GET /funcs?token=<jwt> — листинг через JWT токен
|
||||
// GET /funcs/<namespace> — листинг (Accept: text/html → HTML, иначе plain text)
|
||||
// GET /funcs/<namespace>/source/<fn> — прокси к оператору: файлы исходного кода (JSON)
|
||||
// PATCH /funcs/<namespace>/triggers/<n> — прокси к оператору: enable/disable триггера
|
||||
// GET /health — liveness/readiness probe
|
||||
// GET /funcs — usage hint (без токена)
|
||||
// GET /funcs?token=<jwt> — листинг через JWT токен
|
||||
// GET /funcs/<namespace> — листинг (Accept: text/html → HTML, иначе plain text)
|
||||
// GET /funcs/<namespace>/source/<fn> — прокси к оператору: файлы исходного кода (JSON)
|
||||
// PATCH /funcs/<namespace>/triggers/<n> — прокси к оператору: enable/disable триггера
|
||||
// POST /funcs/<namespace>/create-function — создать функцию через UI (zip собирается на сервере)
|
||||
// POST /funcs/<namespace>/save-function/<fn> — сохранить новый код функции через UI
|
||||
// DELETE /funcs/<namespace>/delete-function/<fn> — удалить функцию через UI
|
||||
// GET /health — liveness/readiness probe
|
||||
//
|
||||
// Env vars:
|
||||
// SLESS_OPERATOR_URL — URL оператора внутри кластера (default: http://sless-operator.sless.svc.cluster.local:9090)
|
||||
@@ -21,7 +24,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
@@ -29,6 +34,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
@@ -180,9 +186,35 @@ func handleFuncsNS(operatorURL, externalURL, serviceToken string, exclude map[st
|
||||
}
|
||||
proxyTriggerPatch(w, r, operatorURL, serviceToken, ns, parts[2])
|
||||
return
|
||||
case "save-function":
|
||||
// POST /funcs/{ns}/save-function/{fnName} — обновить код функции через UI
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
proxySaveFunction(w, r, operatorURL, serviceToken, ns, parts[2])
|
||||
return
|
||||
case "delete-function":
|
||||
// DELETE /funcs/{ns}/delete-function/{fnName} — удалить функцию через UI
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
proxyDeleteFunction(w, r, operatorURL, serviceToken, ns, parts[2])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 2 && parts[1] == "create-function" {
|
||||
// POST /funcs/{ns}/create-function — создать новую функцию через UI
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
proxyCreateFunction(w, r, operatorURL, serviceToken, ns)
|
||||
return
|
||||
}
|
||||
|
||||
if len(parts) > 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -549,3 +581,199 @@ func env(key, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// proxyCreateFunction обрабатывает POST /funcs/{ns}/create-function.
|
||||
// Принимает JSON с кодом функции, создаёт CRD через оператор и сразу загружает zip с кодом.
|
||||
// Это позволяет создать функцию целиком за один запрос из UI без использования terraform.
|
||||
func proxyCreateFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns string) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Runtime string `json:"runtime"`
|
||||
Code string `json:"code"`
|
||||
Filename string `json:"filename"`
|
||||
Entrypoint string `json:"entrypoint"`
|
||||
MemoryMB int `json:"memory_mb"`
|
||||
TimeoutSec int `json:"timeout_sec"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.Runtime == "" || req.Code == "" || req.Filename == "" || req.Entrypoint == "" {
|
||||
http.Error(w, `{"error":"name, runtime, code, filename, entrypoint are required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !isValidK8sName(req.Name) {
|
||||
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.MemoryMB <= 0 {
|
||||
req.MemoryMB = 128
|
||||
}
|
||||
if req.TimeoutSec <= 0 {
|
||||
req.TimeoutSec = 30
|
||||
}
|
||||
|
||||
// Шаг 1: создаём Function CRD через оператор
|
||||
createBody, _ := json.Marshal(map[string]any{
|
||||
"name": req.Name,
|
||||
"runtime": req.Runtime,
|
||||
"entrypoint": req.Entrypoint,
|
||||
"memory_mb": req.MemoryMB,
|
||||
"timeout_sec": req.TimeoutSec,
|
||||
})
|
||||
createResp, err := operatorRequest(r.Context(), http.MethodPost,
|
||||
operatorURL+"/v1/namespaces/"+ns+"/functions", serviceToken, "application/json", bytes.NewReader(createBody))
|
||||
if err != nil || (createResp.StatusCode != http.StatusCreated && createResp.StatusCode != http.StatusOK) {
|
||||
code := http.StatusBadGateway
|
||||
msg := "create function CRD failed"
|
||||
if createResp != nil {
|
||||
b, _ := io.ReadAll(createResp.Body)
|
||||
createResp.Body.Close()
|
||||
msg = string(b)
|
||||
code = createResp.StatusCode
|
||||
}
|
||||
http.Error(w, msg, code)
|
||||
return
|
||||
}
|
||||
createResp.Body.Close()
|
||||
|
||||
// Шаг 2: упаковываем код в zip и загружаем в оператор
|
||||
zipData, err := buildSingleFileZip(req.Filename, req.Code)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"zip build failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := uploadZipToOperator(r.Context(), operatorURL+"/v1/namespaces/"+ns+"/functions/"+req.Name+"/upload", serviceToken, req.Filename, zipData); err != nil {
|
||||
// Функция создана, но загрузка провалилась — возвращаем ошибку, UI должен показать её
|
||||
http.Error(w, "upload code: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
fmt.Fprintf(w, `{"status":"ok","name":%q}`, req.Name)
|
||||
}
|
||||
|
||||
// proxySaveFunction обрабатывает POST /funcs/{ns}/save-function/{fnName}.
|
||||
// Принимает JSON с новым кодом, упаковывает в zip и загружает через оператор.
|
||||
// kind=service → загружает в /services/{name}/upload, иначе → /functions/{name}/upload.
|
||||
func proxySaveFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, fnName string) {
|
||||
if !isValidK8sName(fnName) {
|
||||
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Filename string `json:"filename"`
|
||||
Kind string `json:"kind"` // "function" | "service"
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Code == "" || req.Filename == "" {
|
||||
http.Error(w, `{"error":"code and filename are required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
zipData, err := buildSingleFileZip(req.Filename, req.Code)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"zip build failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resourceType := "functions"
|
||||
if req.Kind == "service" {
|
||||
resourceType = "services"
|
||||
}
|
||||
uploadURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType + "/" + fnName + "/upload"
|
||||
if err := uploadZipToOperator(r.Context(), uploadURL, serviceToken, req.Filename, zipData); err != nil {
|
||||
http.Error(w, "upload code: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"status":"ok","name":%q}`, fnName)
|
||||
}
|
||||
|
||||
// proxyDeleteFunction обрабатывает DELETE /funcs/{ns}/delete-function/{fnName}.
|
||||
// Проксирует DELETE к оператору. kind=service → /services/{name}, иначе → /functions/{name}.
|
||||
func proxyDeleteFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, fnName string) {
|
||||
if !isValidK8sName(fnName) {
|
||||
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resourceType := "functions"
|
||||
if r.URL.Query().Get("kind") == "service" {
|
||||
resourceType = "services"
|
||||
}
|
||||
target := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType + "/" + fnName
|
||||
resp, err := operatorRequest(r.Context(), http.MethodDelete, target, serviceToken, "", nil)
|
||||
if err != nil {
|
||||
http.Error(w, "operator error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
http.Error(w, string(b), resp.StatusCode)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"status":"ok","name":%q}`, fnName)
|
||||
}
|
||||
|
||||
// buildSingleFileZip упаковывает один текстовый файл в zip-архив.
|
||||
// Используется при создании/редактировании функций через UI — код пишется в браузере.
|
||||
func buildSingleFileZip(filename, code string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
f, err := zw.Create(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := io.WriteString(f, code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// uploadZipToOperator отправляет zip-данные как multipart/form-data field "code" на URL оператора.
|
||||
func uploadZipToOperator(ctx context.Context, url, token, filename string, zipData []byte) error {
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
fw, err := mw.CreateFormFile("code", filename+".zip")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fw.Write(zipData); err != nil {
|
||||
return err
|
||||
}
|
||||
mw.Close()
|
||||
|
||||
resp, err := operatorRequest(ctx, http.MethodPost, url, token, mw.FormDataContentType(), &body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// operatorRequest выполняет HTTP-запрос к оператору с авторизацией через serviceToken.
|
||||
func operatorRequest(ctx context.Context, method, url, token, contentType string, body io.Reader) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
return http.DefaultClient.Do(req)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user