feat(web-console): deps, rename, Building poll+timer, kind=service — v0.1.5

This commit is contained in:
Naeel
2026-03-23 10:41:43 +03:00
parent b7fa8acf76
commit 442ba8bc2f
3 changed files with 374 additions and 58 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ spec:
spec: spec:
containers: containers:
- name: funcs - name: funcs
image: naeel/sless-funcs-service:v0.1.4 image: naeel/sless-funcs-service:v0.1.5
ports: ports:
- containerPort: 8090 - containerPort: 8090
env: env:
+172 -16
View File
@@ -487,6 +487,22 @@
transition: background 0.1s; transition: background 0.1s;
} }
.create-fn-btn:hover { background: #2ea043; } .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> </style>
</head> </head>
@@ -543,6 +559,11 @@
<textarea class="modal-code" id="m-code" spellcheck="false"></textarea> <textarea class="modal-code" id="m-code" spellcheck="false"></textarea>
</div> </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-err" id="m-err"></div>
<div class="modal-actions"> <div class="modal-actions">
@@ -590,12 +611,18 @@
$txt(hdr, 'span', 'fn-name', fn.name); $txt(hdr, 'span', 'fn-name', fn.name);
$txt(hdr, 'span', 'badge badge-runtime', fn.runtime); $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) { if (fn.kind) {
var kindLabel = fn.kind === 'service' ? 'always-on' : 'job'; var kindLabel = fn.kind === 'service' ? 'always-on' : 'job';
$txt(hdr, 'span', 'badge badge-kind-' + fn.kind, kindLabel); $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) { if (fn.url) {
var a = document.createElement('a'); var a = document.createElement('a');
a.className = 'fn-url'; a.className = 'fn-url';
@@ -692,7 +719,7 @@
} }
// openEditMode — открывает тело карточки в режиме редактирования. // openEditMode — открывает тело карточки в режиме редактирования.
// Загружает текущий код, показывает textarea, кнопки Сохранить/Закрыть. // Загружает текущий код и файл зависимостей (requirements.txt / package.json).
function openEditMode(body, ns, fnName, fnKind, fnRuntime) { function openEditMode(body, ns, fnName, fnKind, fnRuntime) {
body.innerHTML = ''; body.innerHTML = '';
var msg = $el('div', 'src-msg'); var msg = $el('div', 'src-msg');
@@ -714,11 +741,25 @@
return !f.binary && (f.name.endsWith('.py') || f.name.endsWith('.js')); return !f.binary && (f.name.endsWith('.py') || f.name.endsWith('.js'));
}); });
if (editable.length === 0) { if (editable.length === 0) {
// Нет кода — показываем пустой редактор с шаблоном
editable = [{ name: defaultFilename(fnRuntime), content: helloWorldTemplate(fnRuntime) }]; editable = [{ name: defaultFilename(fnRuntime), content: helloWorldTemplate(fnRuntime) }];
} }
var f = editable[0]; var mainFile = editable[0];
renderEditArea(body, ns, fnName, fnKind, f.name, f.content);
// Ищем файл зависимостей (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) { .catch(function (err) {
@@ -729,15 +770,53 @@
}); });
} }
// renderEditArea — рисует textarea + кнопки Save/Cancel в теле карточки. // renderEditArea — rename input + code textarea + deps textarea + Save/Cancel.
function renderEditArea(body, ns, fnName, fnKind, filename, initialCode) { function renderEditArea(body, ns, fnName, fnKind, fnRuntime, filename, initialCode, depsFilename, depsContent) {
body.innerHTML = ''; 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'); var ta = document.createElement('textarea');
ta.className = 'edit-area'; ta.className = 'edit-area';
ta.spellcheck = false; ta.spellcheck = false;
ta.value = initialCode; ta.value = initialCode;
body.appendChild(ta); 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 actions = $el('div', 'edit-actions');
var saveBtn = $el('button', 'edit-save-btn'); var saveBtn = $el('button', 'edit-save-btn');
saveBtn.textContent = 'Сохранить'; saveBtn.textContent = 'Сохранить';
@@ -756,7 +835,11 @@
fetch('/funcs/' + ns + '/save-function/' + fnName, { fetch('/funcs/' + ns + '/save-function/' + fnName, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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) { }).then(function (r) {
if (r.ok) { if (r.ok) {
status.textContent = '✓ Сохранено — сборка запущена'; status.textContent = '✓ Сохранено — сборка запущена';
@@ -770,15 +853,79 @@
}).catch(function (err) { }).catch(function (err) {
status.textContent = '✗ ' + err; status.textContent = '✗ ' + err;
status.className = 'edit-status err'; status.className = 'edit-status err';
}).finally(function () { }).finally(function () { saveBtn.disabled = false; });
saveBtn.disabled = false;
});
}); });
cancelBtn.addEventListener('click', function () { cancelBtn.addEventListener('click', function () { body.style.display = 'none'; });
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) { function makeTriggerBtn(tr, ns) {
var btn = $el('button', 'trigger-btn ' + (tr.enabled ? 'state-on' : 'state-off')); var btn = $el('button', 'trigger-btn ' + (tr.enabled ? 'state-on' : 'state-off'));
@@ -862,11 +1009,15 @@
var TEMPLATES = { var TEMPLATES = {
'python3.11': { 'python3.11': {
filename: 'handler.py', 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': { 'nodejs20': {
filename: 'handler.js', 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) { if (tpl) {
mCode.value = tpl.code; mCode.value = tpl.code;
mFilenameDisplay.textContent = tpl.filename; mFilenameDisplay.textContent = tpl.filename;
document.getElementById('m-deps-name').textContent = tpl.depsFilename;
document.getElementById('m-deps').value = tpl.deps || '';
} }
} }
applyTemplate(); applyTemplate();
@@ -940,11 +1093,14 @@
body: JSON.stringify({ body: JSON.stringify({
name: name, name: name,
runtime: runtime, runtime: runtime,
kind: 'service',
code: code, code: code,
filename: filename, filename: filename,
entrypoint: entrypoint, entrypoint: entrypoint,
memory_mb: memory, 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) { }).then(function (r) {
if (r.ok) { if (r.ok) {
+186 -26
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-23 (добавлены эндпоинты создания/редактирования/удаления функций через UI) // Изменено: 2026-03-23 (deps поддержка, Building poll, rename, kind=service по умолч.)
// main.go — глобальный HTTP сервис листинга функций пользователя. // main.go — глобальный HTTP сервис листинга функций пользователя.
// Развёрнут ОДИН РАЗ в namespace sless; работает для ВСЕХ пользователей. // Развёрнут ОДИН РАЗ в namespace sless; работает для ВСЕХ пользователей.
// Не связан с terraform — деплоится манифестом deployments/k8s/funcs-service.yaml. // Не связан с terraform — деплоится манифестом deployments/k8s/funcs-service.yaml.
@@ -7,10 +7,12 @@
// GET /funcs — usage hint (без токена) // GET /funcs — usage hint (без токена)
// GET /funcs?token=<jwt> — листинг через JWT токен // GET /funcs?token=<jwt> — листинг через JWT токен
// GET /funcs/<namespace> — листинг (Accept: text/html → HTML, иначе plain text) // GET /funcs/<namespace> — листинг (Accept: text/html → HTML, иначе plain text)
// GET /funcs/<namespace>/source/<fn> — прокси к оператору: файлы исходного кода (JSON) // GET /funcs/<namespace>/source/<fn> — прокси: исходный код (JSON)
// PATCH /funcs/<namespace>/triggers/<n> — прокси к оператору: enable/disable триггера // PATCH /funcs/<namespace>/triggers/<n> — прокси: enable/disable триггера
// POST /funcs/<namespace>/create-function — создать функцию через UI (zip собирается на сервере) // GET /funcs/<namespace>/fn-status/<fn> — прокси: текущий phase+url функции (для UI polling)
// POST /funcs/<namespace>/save-function/<fn> — сохранить новый код функции через UI // 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 // DELETE /funcs/<namespace>/delete-function/<fn> — удалить функцию через UI
// GET /health — liveness/readiness probe // GET /health — liveness/readiness probe
// //
@@ -186,6 +188,14 @@ func handleFuncsNS(operatorURL, externalURL, serviceToken string, exclude map[st
} }
proxyTriggerPatch(w, r, operatorURL, serviceToken, ns, parts[2]) proxyTriggerPatch(w, r, operatorURL, serviceToken, ns, parts[2])
return 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": case "save-function":
// POST /funcs/{ns}/save-function/{fnName} — обновить код функции через UI // POST /funcs/{ns}/save-function/{fnName} — обновить код функции через UI
if r.Method != http.MethodPost { 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]) proxySaveFunction(w, r, operatorURL, serviceToken, ns, parts[2])
return 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": case "delete-function":
// DELETE /funcs/{ns}/delete-function/{fnName} — удалить функцию через UI // DELETE /funcs/{ns}/delete-function/{fnName} — удалить функцию через UI
if r.Method != http.MethodDelete { if r.Method != http.MethodDelete {
@@ -583,14 +601,17 @@ func env(key, fallback string) string {
} }
// proxyCreateFunction обрабатывает POST /funcs/{ns}/create-function. // proxyCreateFunction обрабатывает POST /funcs/{ns}/create-function.
// Принимает JSON с кодом функции, создаёт CRD через оператор и сразу загружает zip с кодом. // Создаёт функцию как sless_service (always-on, с URL) если kind не указан.
// Это позволяет создать функцию целиком за один запрос из UI без использования terraform. // Принимает code (основной файл) + deps (requirements.txt / package.json).
func proxyCreateFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns string) { func proxyCreateFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns string) {
var req struct { var req struct {
Name string `json:"name"` Name string `json:"name"`
Runtime string `json:"runtime"` Runtime string `json:"runtime"`
Kind string `json:"kind"` // "service" | "function"; default "service"
Code string `json:"code"` Code string `json:"code"`
Filename string `json:"filename"` 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"` Entrypoint string `json:"entrypoint"`
MemoryMB int `json:"memory_mb"` MemoryMB int `json:"memory_mb"`
TimeoutSec int `json:"timeout_sec"` TimeoutSec int `json:"timeout_sec"`
@@ -607,6 +628,9 @@ func proxyCreateFunction(w http.ResponseWriter, r *http.Request, operatorURL, se
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest) http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
return return
} }
if req.Kind == "" {
req.Kind = "service" // always-on по умолчанию — сразу получает URL
}
if req.MemoryMB <= 0 { if req.MemoryMB <= 0 {
req.MemoryMB = 128 req.MemoryMB = 128
} }
@@ -614,7 +638,12 @@ func proxyCreateFunction(w http.ResponseWriter, r *http.Request, operatorURL, se
req.TimeoutSec = 30 req.TimeoutSec = 30
} }
// Шаг 1: создаём Function CRD через оператор resourceType := "functions"
if req.Kind == "service" {
resourceType = "services"
}
// Шаг 1: создаём CRD через оператор (/functions или /services)
createBody, _ := json.Marshal(map[string]any{ createBody, _ := json.Marshal(map[string]any{
"name": req.Name, "name": req.Name,
"runtime": req.Runtime, "runtime": req.Runtime,
@@ -622,11 +651,11 @@ func proxyCreateFunction(w http.ResponseWriter, r *http.Request, operatorURL, se
"memory_mb": req.MemoryMB, "memory_mb": req.MemoryMB,
"timeout_sec": req.TimeoutSec, "timeout_sec": req.TimeoutSec,
}) })
createResp, err := operatorRequest(r.Context(), http.MethodPost, createURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType
operatorURL+"/v1/namespaces/"+ns+"/functions", serviceToken, "application/json", bytes.NewReader(createBody)) 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) { if err != nil || (createResp.StatusCode != http.StatusCreated && createResp.StatusCode != http.StatusOK) {
code := http.StatusBadGateway code := http.StatusBadGateway
msg := "create function CRD failed" msg := "create CRD failed"
if createResp != nil { if createResp != nil {
b, _ := io.ReadAll(createResp.Body) b, _ := io.ReadAll(createResp.Body)
createResp.Body.Close() createResp.Body.Close()
@@ -638,25 +667,29 @@ func proxyCreateFunction(w http.ResponseWriter, r *http.Request, operatorURL, se
} }
createResp.Body.Close() createResp.Body.Close()
// Шаг 2: упаковываем код в zip и загружаем в оператор // Шаг 2: упаковываем code + deps в zip и загружаем
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 { if err != nil {
http.Error(w, `{"error":"zip build failed"}`, http.StatusInternalServerError) http.Error(w, `{"error":"zip build failed"}`, http.StatusInternalServerError)
return return
} }
if err := uploadZipToOperator(r.Context(), operatorURL+"/v1/namespaces/"+ns+"/functions/"+req.Name+"/upload", serviceToken, req.Filename, zipData); err != nil { uploadURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType + "/" + req.Name + "/upload"
// Функция создана, но загрузка провалилась — возвращаем ошибку, UI должен показать её if err := uploadZipToOperator(r.Context(), uploadURL, serviceToken, req.Filename, zipData); err != nil {
http.Error(w, "upload code: "+err.Error(), http.StatusBadGateway) http.Error(w, "upload code: "+err.Error(), http.StatusBadGateway)
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) 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}. // proxySaveFunction обрабатывает POST /funcs/{ns}/save-function/{fnName}.
// Принимает JSON с новым кодом, упаковывает в zip и загружает через оператор. // Принимает code + deps (опционально), упаковывает в zip и загружает через оператор.
// kind=service → загружает в /services/{name}/upload, иначе → /functions/{name}/upload. // kind=service → загружает в /services/{name}/upload, иначе → /functions/{name}/upload.
func proxySaveFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, fnName string) { func proxySaveFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, fnName string) {
if !isValidK8sName(fnName) { if !isValidK8sName(fnName) {
@@ -667,13 +700,19 @@ func proxySaveFunction(w http.ResponseWriter, r *http.Request, operatorURL, serv
Code string `json:"code"` Code string `json:"code"`
Filename string `json:"filename"` 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 == "" { 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) http.Error(w, `{"error":"code and filename are required"}`, http.StatusBadRequest)
return 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 { if err != nil {
http.Error(w, `{"error":"zip build failed"}`, http.StatusInternalServerError) http.Error(w, `{"error":"zip build failed"}`, http.StatusInternalServerError)
return return
@@ -720,22 +759,143 @@ func proxyDeleteFunction(w http.ResponseWriter, r *http.Request, operatorURL, se
fmt.Fprintf(w, `{"status":"ok","name":%q}`, fnName) fmt.Fprintf(w, `{"status":"ok","name":%q}`, fnName)
} }
// buildSingleFileZip упаковывает один текстовый файл в zip-архив. // buildZipFiles упаковывает набор текстовых файлов {filename→content} в zip-архив.
// Используется при создании/редактировании функций через UI — код пишется в браузере. // Используется при создании/редактировании функций через UI.
func buildSingleFileZip(filename, code string) ([]byte, error) { // Поддерживает code + deps (requirements.txt / package.json) в одном архиве.
func buildZipFiles(files map[string]string) ([]byte, error) {
var buf bytes.Buffer var buf bytes.Buffer
zw := zip.NewWriter(&buf) zw := zip.NewWriter(&buf)
f, err := zw.Create(filename) for name, content := range files {
f, err := zw.Create(name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if _, err := io.WriteString(f, code); err != nil { if _, err := io.WriteString(f, content); err != nil {
return nil, err return nil, err
} }
if err := zw.Close(); err != nil {
return nil, err
} }
return buf.Bytes(), nil 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 {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
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
}
var req struct {
NewName string `json:"new_name"`
Kind string `json:"kind"`
}
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 оператора. // uploadZipToOperator отправляет zip-данные как multipart/form-data field "code" на URL оператора.