diff --git a/services/funcs/funcs-service.yaml b/services/funcs/funcs-service.yaml
index ed406cc..8278fd9 100644
--- a/services/funcs/funcs-service.yaml
+++ b/services/funcs/funcs-service.yaml
@@ -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:
diff --git a/services/funcs/index.html b/services/funcs/index.html
index 26586a2..3380631 100644
--- a/services/funcs/index.html
+++ b/services/funcs/index.html
@@ -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; }
+ }
@@ -543,6 +559,11 @@
+
+
+
+
+
@@ -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) {
diff --git a/services/funcs/main.go b/services/funcs/main.go
index e9d2593..ee7f657 100644
--- a/services/funcs/main.go
+++ b/services/funcs/main.go
@@ -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 токен
-// GET /funcs/ — листинг (Accept: text/html → HTML, иначе plain text)
-// GET /funcs//source/ — прокси к оператору: файлы исходного кода (JSON)
-// PATCH /funcs//triggers/ — прокси к оператору: enable/disable триггера
-// POST /funcs//create-function — создать функцию через UI (zip собирается на сервере)
-// POST /funcs//save-function/ — сохранить новый код функции через UI
-// DELETE /funcs//delete-function/ — удалить функцию через UI
-// GET /health — liveness/readiness probe
+// GET /funcs — usage hint (без токена)
+// GET /funcs?token= — листинг через JWT токен
+// GET /funcs/ — листинг (Accept: text/html → HTML, иначе plain text)
+// GET /funcs//source/ — прокси: исходный код (JSON)
+// PATCH /funcs//triggers/ — прокси: enable/disable триггера
+// GET /funcs//fn-status/ — прокси: текущий phase+url функции (для UI polling)
+// POST /funcs//create-function — создать функцию через UI (kind=service по умолч.)
+// POST /funcs//save-function/ — обновить код через UI (zip с code+deps)
+// POST /funcs//rename-function/ — переименовать: delete old + create new с тем же кодом
+// DELETE /funcs//delete-function/ — удалить функцию через 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 оператора.