feat(web-console): deps, rename, Building poll+timer, kind=service — v0.1.5
This commit is contained in:
@@ -27,7 +27,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: funcs
|
||||
image: naeel/sless-funcs-service:v0.1.4
|
||||
image: naeel/sless-funcs-service:v0.1.5
|
||||
ports:
|
||||
- containerPort: 8090
|
||||
env:
|
||||
|
||||
+172
-16
@@ -487,6 +487,22 @@
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.create-fn-btn:hover { background: #2ea043; }
|
||||
|
||||
/* Таймер Building */
|
||||
.fn-timer {
|
||||
font-size: 0.72rem;
|
||||
color: #d29922;
|
||||
font-family: monospace;
|
||||
margin-left: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.badge-Building {
|
||||
animation: pulse-bld 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-bld {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -543,6 +559,11 @@
|
||||
<textarea class="modal-code" id="m-code" spellcheck="false"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label><span id="m-deps-name">requirements.txt</span> <span style="color:#484f58;font-size:0.75rem">(зависимости, опционально)</span></label>
|
||||
<textarea class="modal-code" id="m-deps" spellcheck="false" style="min-height:80px"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="modal-err" id="m-err"></div>
|
||||
|
||||
<div class="modal-actions">
|
||||
@@ -590,12 +611,18 @@
|
||||
|
||||
$txt(hdr, 'span', 'fn-name', fn.name);
|
||||
$txt(hdr, 'span', 'badge badge-runtime', fn.runtime);
|
||||
$txt(hdr, 'span', 'badge badge-' + fn.phase, fn.phase || 'Pending');
|
||||
var phaseBadge = $txt(hdr, 'span', 'badge badge-' + fn.phase, fn.phase || 'Pending');
|
||||
if (fn.kind) {
|
||||
var kindLabel = fn.kind === 'service' ? 'always-on' : 'job';
|
||||
$txt(hdr, 'span', 'badge badge-kind-' + fn.kind, kindLabel);
|
||||
}
|
||||
|
||||
// Для Building/Pending — таймер секунд + автополинг до Ready/Failed
|
||||
if (fn.phase === 'Building' || fn.phase === 'Pending') {
|
||||
var timerEl = $txt(hdr, 'span', 'fn-timer', '0с');
|
||||
startBuildPoll(hdr, phaseBadge, timerEl, fn, ns);
|
||||
}
|
||||
|
||||
if (fn.url) {
|
||||
var a = document.createElement('a');
|
||||
a.className = 'fn-url';
|
||||
@@ -692,7 +719,7 @@
|
||||
}
|
||||
|
||||
// openEditMode — открывает тело карточки в режиме редактирования.
|
||||
// Загружает текущий код, показывает textarea, кнопки Сохранить/Закрыть.
|
||||
// Загружает текущий код и файл зависимостей (requirements.txt / package.json).
|
||||
function openEditMode(body, ns, fnName, fnKind, fnRuntime) {
|
||||
body.innerHTML = '';
|
||||
var msg = $el('div', 'src-msg');
|
||||
@@ -714,11 +741,25 @@
|
||||
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);
|
||||
var mainFile = editable[0];
|
||||
|
||||
// Ищем файл зависимостей (requirements.txt / package.json)
|
||||
var tpl = TEMPLATES[fnRuntime] || {};
|
||||
var depsFilename = tpl.depsFilename || '';
|
||||
var depsContent = tpl.deps || '';
|
||||
if (depsFilename) {
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
if (files[i].name === depsFilename && !files[i].binary) {
|
||||
depsContent = files[i].content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderEditArea(body, ns, fnName, fnKind, fnRuntime,
|
||||
mainFile.name, mainFile.content, depsFilename, depsContent);
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
@@ -729,15 +770,53 @@
|
||||
});
|
||||
}
|
||||
|
||||
// renderEditArea — рисует textarea + кнопки Save/Cancel в теле карточки.
|
||||
function renderEditArea(body, ns, fnName, fnKind, filename, initialCode) {
|
||||
// renderEditArea — rename input + code textarea + deps textarea + Save/Cancel.
|
||||
function renderEditArea(body, ns, fnName, fnKind, fnRuntime, filename, initialCode, depsFilename, depsContent) {
|
||||
body.innerHTML = '';
|
||||
|
||||
// Строка переименования
|
||||
var renameRow = $el('div', 'edit-actions');
|
||||
renameRow.style.borderBottom = '1px solid #30363d';
|
||||
var nameLabel = $el('span', 'edit-status');
|
||||
nameLabel.textContent = 'Имя:';
|
||||
var nameInput = document.createElement('input');
|
||||
nameInput.type = 'text';
|
||||
nameInput.value = fnName;
|
||||
nameInput.style.cssText = 'flex:1;max-width:240px;background:#0d1117;border:1px solid #30363d;border-radius:5px;color:#e6edf3;padding:4px 8px;font-size:0.8rem;outline:none;';
|
||||
var renameBtn = $el('button', 'edit-cancel-btn');
|
||||
renameBtn.textContent = 'Переименовать';
|
||||
var renameStatus = $el('span', 'edit-status');
|
||||
renameRow.appendChild(nameLabel);
|
||||
renameRow.appendChild(nameInput);
|
||||
renameRow.appendChild(renameBtn);
|
||||
renameRow.appendChild(renameStatus);
|
||||
body.appendChild(renameRow);
|
||||
|
||||
// Код функции
|
||||
var codeHdr = $el('div', 'file-header');
|
||||
codeHdr.textContent = filename;
|
||||
body.appendChild(codeHdr);
|
||||
var ta = document.createElement('textarea');
|
||||
ta.className = 'edit-area';
|
||||
ta.spellcheck = false;
|
||||
ta.value = initialCode;
|
||||
body.appendChild(ta);
|
||||
|
||||
// Файл зависимостей (если есть)
|
||||
var depsTA = null;
|
||||
if (depsFilename) {
|
||||
var depsHdr = $el('div', 'file-header');
|
||||
depsHdr.textContent = depsFilename;
|
||||
body.appendChild(depsHdr);
|
||||
depsTA = document.createElement('textarea');
|
||||
depsTA.className = 'edit-area';
|
||||
depsTA.style.minHeight = '80px';
|
||||
depsTA.spellcheck = false;
|
||||
depsTA.value = depsContent;
|
||||
body.appendChild(depsTA);
|
||||
}
|
||||
|
||||
// Кнопки Save / Cancel
|
||||
var actions = $el('div', 'edit-actions');
|
||||
var saveBtn = $el('button', 'edit-save-btn');
|
||||
saveBtn.textContent = 'Сохранить';
|
||||
@@ -756,7 +835,11 @@
|
||||
fetch('/funcs/' + ns + '/save-function/' + fnName, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: ta.value, filename: filename, kind: fnKind || 'function' })
|
||||
body: JSON.stringify({
|
||||
code: ta.value, filename: filename, kind: fnKind || 'function',
|
||||
deps: depsTA ? depsTA.value : '',
|
||||
deps_filename: depsFilename || ''
|
||||
})
|
||||
}).then(function (r) {
|
||||
if (r.ok) {
|
||||
status.textContent = '✓ Сохранено — сборка запущена';
|
||||
@@ -770,16 +853,80 @@
|
||||
}).catch(function (err) {
|
||||
status.textContent = '✗ ' + err;
|
||||
status.className = 'edit-status err';
|
||||
}).finally(function () {
|
||||
saveBtn.disabled = false;
|
||||
});
|
||||
}).finally(function () { saveBtn.disabled = false; });
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', function () {
|
||||
body.style.display = 'none';
|
||||
cancelBtn.addEventListener('click', function () { body.style.display = 'none'; });
|
||||
|
||||
renameBtn.addEventListener('click', function () {
|
||||
var newName = nameInput.value.trim();
|
||||
if (newName === fnName) { renameStatus.textContent = 'Имя не изменилось'; return; }
|
||||
if (!/^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$/.test(newName)) {
|
||||
renameStatus.textContent = '✗ Только строчные буквы, цифры, дефисы';
|
||||
renameStatus.className = 'edit-status err';
|
||||
return;
|
||||
}
|
||||
renameBtn.disabled = true;
|
||||
renameStatus.textContent = 'Переименовываю…';
|
||||
renameStatus.className = 'edit-status';
|
||||
fetch('/funcs/' + ns + '/rename-function/' + fnName, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ new_name: newName, kind: fnKind || 'function' })
|
||||
}).then(function (r) {
|
||||
if (r.ok) {
|
||||
renameStatus.textContent = '✓ Переименовано → ' + newName;
|
||||
renameStatus.className = 'edit-status ok';
|
||||
setTimeout(function () { location.reload(); }, 800);
|
||||
} else {
|
||||
return r.text().then(function (t) {
|
||||
renameStatus.textContent = '✗ ' + t;
|
||||
renameStatus.className = 'edit-status err';
|
||||
});
|
||||
}
|
||||
}).catch(function (err) {
|
||||
renameStatus.textContent = '✗ ' + err;
|
||||
renameStatus.className = 'edit-status err';
|
||||
}).finally(function () { renameBtn.disabled = false; });
|
||||
});
|
||||
}
|
||||
|
||||
// startBuildPoll — опрашивает /fn-status каждые 5с пока phase=Building/Pending.
|
||||
// Показывает таймер в секундах, обновляет badge, добавляет URL когда сервис готов.
|
||||
function startBuildPoll(hdr, phaseBadge, timerEl, fn, ns) {
|
||||
var buildStart = Date.now();
|
||||
var timerItv = setInterval(function () {
|
||||
timerEl.textContent = Math.floor((Date.now() - buildStart) / 1000) + 'с';
|
||||
}, 1000);
|
||||
var pollItv = setInterval(function () {
|
||||
fetch('/funcs/' + ns + '/fn-status/' + fn.name + '?kind=' + (fn.kind || 'function'))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d.phase || d.phase === 'Building' || d.phase === 'Pending') return;
|
||||
clearInterval(timerItv);
|
||||
clearInterval(pollItv);
|
||||
var elapsed = Math.floor((Date.now() - buildStart) / 1000);
|
||||
phaseBadge.className = 'badge badge-' + d.phase;
|
||||
phaseBadge.textContent = d.phase;
|
||||
timerEl.textContent = '(' + elapsed + 'с)';
|
||||
// Для сервиса — добавляем URL в заголовок карточки при появлении
|
||||
if (d.url && !fn.url) {
|
||||
fn.url = d.url;
|
||||
var urlEl = document.createElement('a');
|
||||
urlEl.className = 'fn-url';
|
||||
urlEl.href = d.url;
|
||||
urlEl.target = '_blank';
|
||||
urlEl.rel = 'noopener noreferrer';
|
||||
urlEl.textContent = d.url;
|
||||
urlEl.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
var spacer = hdr.querySelector('.spacer');
|
||||
if (spacer) hdr.insertBefore(urlEl, spacer);
|
||||
}
|
||||
})
|
||||
.catch(function () {}); // тихо — нет смысла шуметь
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function makeTriggerBtn(tr, ns) {
|
||||
var btn = $el('button', 'trigger-btn ' + (tr.enabled ? 'state-on' : 'state-off'));
|
||||
btn.textContent = tr.enabled ? '■ стоп' : '▶ запуск';
|
||||
@@ -862,11 +1009,15 @@
|
||||
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'
|
||||
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',
|
||||
depsFilename: 'requirements.txt',
|
||||
deps: '# Python packages\n# e.g.: requests==2.31.0\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'
|
||||
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',
|
||||
depsFilename: 'package.json',
|
||||
deps: '{\n "dependencies": {}\n}\n'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -889,6 +1040,8 @@
|
||||
if (tpl) {
|
||||
mCode.value = tpl.code;
|
||||
mFilenameDisplay.textContent = tpl.filename;
|
||||
document.getElementById('m-deps-name').textContent = tpl.depsFilename;
|
||||
document.getElementById('m-deps').value = tpl.deps || '';
|
||||
}
|
||||
}
|
||||
applyTemplate();
|
||||
@@ -940,11 +1093,14 @@
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
runtime: runtime,
|
||||
kind: 'service',
|
||||
code: code,
|
||||
filename: filename,
|
||||
entrypoint: entrypoint,
|
||||
memory_mb: memory,
|
||||
timeout_sec: timeout
|
||||
timeout_sec: timeout,
|
||||
deps: document.getElementById('m-deps').value,
|
||||
deps_filename: TEMPLATES[runtime] ? TEMPLATES[runtime].depsFilename : ''
|
||||
})
|
||||
}).then(function (r) {
|
||||
if (r.ok) {
|
||||
|
||||
+201
-41
@@ -1,18 +1,20 @@
|
||||
// Изменено: 2026-03-23 (добавлены эндпоинты создания/редактирования/удаления функций через UI)
|
||||
// Изменено: 2026-03-23 (deps поддержка, Building poll, rename, kind=service по умолч.)
|
||||
// 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 триггера
|
||||
// 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
|
||||
// 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 /funcs/<namespace>/fn-status/<fn> — прокси: текущий phase+url функции (для UI polling)
|
||||
// POST /funcs/<namespace>/create-function — создать функцию через UI (kind=service по умолч.)
|
||||
// POST /funcs/<namespace>/save-function/<fn> — обновить код через UI (zip с code+deps)
|
||||
// POST /funcs/<namespace>/rename-function/<fn> — переименовать: delete old + create new с тем же кодом
|
||||
// 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)
|
||||
@@ -186,6 +188,14 @@ func handleFuncsNS(operatorURL, externalURL, serviceToken string, exclude map[st
|
||||
}
|
||||
proxyTriggerPatch(w, r, operatorURL, serviceToken, ns, parts[2])
|
||||
return
|
||||
case "fn-status":
|
||||
// GET /funcs/{ns}/fn-status/{fn} — текущий phase+url для UI polling
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
proxyFnStatus(w, r, operatorURL, serviceToken, ns, parts[2])
|
||||
return
|
||||
case "save-function":
|
||||
// POST /funcs/{ns}/save-function/{fnName} — обновить код функции через UI
|
||||
if r.Method != http.MethodPost {
|
||||
@@ -194,6 +204,14 @@ func handleFuncsNS(operatorURL, externalURL, serviceToken string, exclude map[st
|
||||
}
|
||||
proxySaveFunction(w, r, operatorURL, serviceToken, ns, parts[2])
|
||||
return
|
||||
case "rename-function":
|
||||
// POST /funcs/{ns}/rename-function/{fn} — переименовать: delete+create с тем же кодом
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
proxyRenameFunction(w, r, operatorURL, serviceToken, ns, parts[2])
|
||||
return
|
||||
case "delete-function":
|
||||
// DELETE /funcs/{ns}/delete-function/{fnName} — удалить функцию через UI
|
||||
if r.Method != http.MethodDelete {
|
||||
@@ -583,17 +601,20 @@ func env(key, fallback string) string {
|
||||
}
|
||||
|
||||
// proxyCreateFunction обрабатывает POST /funcs/{ns}/create-function.
|
||||
// Принимает JSON с кодом функции, создаёт CRD через оператор и сразу загружает zip с кодом.
|
||||
// Это позволяет создать функцию целиком за один запрос из UI без использования terraform.
|
||||
// Создаёт функцию как sless_service (always-on, с URL) если kind не указан.
|
||||
// Принимает code (основной файл) + deps (requirements.txt / package.json).
|
||||
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"`
|
||||
Name string `json:"name"`
|
||||
Runtime string `json:"runtime"`
|
||||
Kind string `json:"kind"` // "service" | "function"; default "service"
|
||||
Code string `json:"code"`
|
||||
Filename string `json:"filename"`
|
||||
Deps string `json:"deps"` // содержимое requirements.txt / package.json
|
||||
DepsFile string `json:"deps_filename"` // "requirements.txt" или "package.json"
|
||||
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)
|
||||
@@ -607,6 +628,9 @@ func proxyCreateFunction(w http.ResponseWriter, r *http.Request, operatorURL, se
|
||||
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Kind == "" {
|
||||
req.Kind = "service" // always-on по умолчанию — сразу получает URL
|
||||
}
|
||||
if req.MemoryMB <= 0 {
|
||||
req.MemoryMB = 128
|
||||
}
|
||||
@@ -614,7 +638,12 @@ func proxyCreateFunction(w http.ResponseWriter, r *http.Request, operatorURL, se
|
||||
req.TimeoutSec = 30
|
||||
}
|
||||
|
||||
// Шаг 1: создаём Function CRD через оператор
|
||||
resourceType := "functions"
|
||||
if req.Kind == "service" {
|
||||
resourceType = "services"
|
||||
}
|
||||
|
||||
// Шаг 1: создаём CRD через оператор (/functions или /services)
|
||||
createBody, _ := json.Marshal(map[string]any{
|
||||
"name": req.Name,
|
||||
"runtime": req.Runtime,
|
||||
@@ -622,11 +651,11 @@ func proxyCreateFunction(w http.ResponseWriter, r *http.Request, operatorURL, se
|
||||
"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))
|
||||
createURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType
|
||||
createResp, err := operatorRequest(r.Context(), http.MethodPost, createURL, 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"
|
||||
msg := "create CRD failed"
|
||||
if createResp != nil {
|
||||
b, _ := io.ReadAll(createResp.Body)
|
||||
createResp.Body.Close()
|
||||
@@ -638,25 +667,29 @@ func proxyCreateFunction(w http.ResponseWriter, r *http.Request, operatorURL, se
|
||||
}
|
||||
createResp.Body.Close()
|
||||
|
||||
// Шаг 2: упаковываем код в zip и загружаем в оператор
|
||||
zipData, err := buildSingleFileZip(req.Filename, req.Code)
|
||||
// Шаг 2: упаковываем code + deps в zip и загружаем
|
||||
zipFiles := map[string]string{req.Filename: req.Code}
|
||||
if req.Deps != "" && req.DepsFile != "" {
|
||||
zipFiles[req.DepsFile] = req.Deps
|
||||
}
|
||||
zipData, err := buildZipFiles(zipFiles)
|
||||
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 должен показать её
|
||||
uploadURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType + "/" + req.Name + "/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")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
fmt.Fprintf(w, `{"status":"ok","name":%q}`, req.Name)
|
||||
fmt.Fprintf(w, `{"status":"ok","name":%q,"kind":%q}`, req.Name, req.Kind)
|
||||
}
|
||||
|
||||
// proxySaveFunction обрабатывает POST /funcs/{ns}/save-function/{fnName}.
|
||||
// Принимает JSON с новым кодом, упаковывает в zip и загружает через оператор.
|
||||
// Принимает code + deps (опционально), упаковывает в 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) {
|
||||
@@ -666,14 +699,20 @@ func proxySaveFunction(w http.ResponseWriter, r *http.Request, operatorURL, serv
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Filename string `json:"filename"`
|
||||
Kind string `json:"kind"` // "function" | "service"
|
||||
Kind string `json:"kind"` // "function" | "service"
|
||||
Deps string `json:"deps"` // содержимое requirements.txt / package.json
|
||||
DepsFile string `json:"deps_filename"` // имя файла зависимостей
|
||||
}
|
||||
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)
|
||||
zipFiles := map[string]string{req.Filename: req.Code}
|
||||
if req.Deps != "" && req.DepsFile != "" {
|
||||
zipFiles[req.DepsFile] = req.Deps
|
||||
}
|
||||
zipData, err := buildZipFiles(zipFiles)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"zip build failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
@@ -720,22 +759,143 @@ func proxyDeleteFunction(w http.ResponseWriter, r *http.Request, operatorURL, se
|
||||
fmt.Fprintf(w, `{"status":"ok","name":%q}`, fnName)
|
||||
}
|
||||
|
||||
// buildSingleFileZip упаковывает один текстовый файл в zip-архив.
|
||||
// Используется при создании/редактировании функций через UI — код пишется в браузере.
|
||||
func buildSingleFileZip(filename, code string) ([]byte, error) {
|
||||
// buildZipFiles упаковывает набор текстовых файлов {filename→content} в zip-архив.
|
||||
// Используется при создании/редактировании функций через UI.
|
||||
// Поддерживает code + deps (requirements.txt / package.json) в одном архиве.
|
||||
func buildZipFiles(files map[string]string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
f, err := zw.Create(filename)
|
||||
for name, content := range files {
|
||||
f, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := io.WriteString(f, content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return buf.Bytes(), zw.Close()
|
||||
}
|
||||
|
||||
// proxyFnStatus отдаёт текущий phase+url функции/сервиса — используется для UI polling.
|
||||
// Клиент опрашивает каждые 5с пока phase=Building, потом останавливается.
|
||||
func proxyFnStatus(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, fnName string) {
|
||||
if !isValidK8sName(fnName) {
|
||||
http.Error(w, `{"error":"invalid name"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resourceType := "functions"
|
||||
if r.URL.Query().Get("kind") == "service" {
|
||||
resourceType = "services"
|
||||
}
|
||||
resp, err := operatorRequest(r.Context(), http.MethodGet,
|
||||
operatorURL+"/v1/namespaces/"+ns+"/"+resourceType+"/"+fnName, serviceToken, "", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if _, err := io.WriteString(f, code); err != nil {
|
||||
return nil, err
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
w.Write(b) //nolint:errcheck
|
||||
}
|
||||
|
||||
// proxyRenameFunction реализует переименование функции: delete old + create new + upload old code.
|
||||
// Это атомарная операция с точки зрения UI, но не транзакционная на уровне k8s.
|
||||
// При сбое на шагах 3-5 старая функция может остаться (UI покажет ошибку).
|
||||
func proxyRenameFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, oldName string) {
|
||||
if !isValidK8sName(oldName) {
|
||||
http.Error(w, `{"error":"invalid old name"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
var req struct {
|
||||
NewName string `json:"new_name"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.NewName == "" {
|
||||
http.Error(w, `{"error":"new_name required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !isValidK8sName(req.NewName) {
|
||||
http.Error(w, `{"error":"invalid new_name"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resourceType := "functions"
|
||||
if req.Kind == "service" {
|
||||
resourceType = "services"
|
||||
}
|
||||
ctx := r.Context()
|
||||
baseURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType
|
||||
|
||||
// Шаг 1: получаем конфиг старой функции (runtime, entrypoint, memory, timeout)
|
||||
cfgResp, err := operatorRequest(ctx, http.MethodGet, baseURL+"/"+oldName, serviceToken, "", nil)
|
||||
if err != nil || cfgResp.StatusCode != http.StatusOK {
|
||||
http.Error(w, "get old config failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
var cfg struct {
|
||||
Runtime string `json:"runtime"`
|
||||
Entrypoint string `json:"entrypoint"`
|
||||
MemoryMB int `json:"memory_mb"`
|
||||
TimeoutSec int `json:"timeout_sec"`
|
||||
Env map[string]string `json:"env"`
|
||||
}
|
||||
json.NewDecoder(cfgResp.Body).Decode(&cfg) //nolint:errcheck
|
||||
cfgResp.Body.Close()
|
||||
|
||||
// Шаг 2: получаем исходный код старой функции
|
||||
srcResp, _ := operatorRequest(ctx, http.MethodGet, baseURL+"/"+oldName+"/source", serviceToken, "", nil)
|
||||
zipFiles := map[string]string{}
|
||||
if srcResp != nil && srcResp.StatusCode == http.StatusOK {
|
||||
var files []struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Binary bool `json:"binary"`
|
||||
}
|
||||
if json.NewDecoder(srcResp.Body).Decode(&files) == nil {
|
||||
for _, f := range files {
|
||||
if !f.Binary {
|
||||
zipFiles[f.Name] = f.Content
|
||||
}
|
||||
}
|
||||
}
|
||||
srcResp.Body.Close()
|
||||
}
|
||||
|
||||
// Шаг 3: создаём новую функцию с новым именем
|
||||
createBody, _ := json.Marshal(map[string]any{
|
||||
"name": req.NewName, "runtime": cfg.Runtime, "entrypoint": cfg.Entrypoint,
|
||||
"memory_mb": cfg.MemoryMB, "timeout_sec": cfg.TimeoutSec, "env": cfg.Env,
|
||||
})
|
||||
createResp, err := operatorRequest(ctx, http.MethodPost, baseURL, serviceToken, "application/json", bytes.NewReader(createBody))
|
||||
if err != nil || (createResp.StatusCode != http.StatusCreated && createResp.StatusCode != http.StatusOK) {
|
||||
msg := "create new failed"
|
||||
if createResp != nil {
|
||||
b, _ := io.ReadAll(createResp.Body)
|
||||
createResp.Body.Close()
|
||||
msg = string(b)
|
||||
}
|
||||
http.Error(w, msg, http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
createResp.Body.Close()
|
||||
|
||||
// Шаг 4: загружаем старый код в новую функцию (если есть)
|
||||
if len(zipFiles) > 0 {
|
||||
if zipData, err := buildZipFiles(zipFiles); err == nil {
|
||||
_ = uploadZipToOperator(ctx, baseURL+"/"+req.NewName+"/upload", serviceToken, req.NewName, zipData)
|
||||
}
|
||||
}
|
||||
|
||||
// Шаг 5: удаляем старую функцию
|
||||
delResp, _ := operatorRequest(ctx, http.MethodDelete, baseURL+"/"+oldName, serviceToken, "", nil)
|
||||
if delResp != nil {
|
||||
delResp.Body.Close()
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"status":"ok","old":%q,"new":%q,"kind":%q}`, oldName, req.NewName, req.Kind)
|
||||
}
|
||||
|
||||
// uploadZipToOperator отправляет zip-данные как multipart/form-data field "code" на URL оператора.
|
||||
|
||||
Reference in New Issue
Block a user