From 1fa92e069f2c17938a65502da9e134208f416b5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 9 May 2026 06:50:52 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=BE=D0=BA=D1=80=D1=83=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B9?= =?UTF-8?q?=20(env=20vars=20=D0=B2=20CRD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: handleGetFunctionEnvVars / handlePutFunctionEnvVars в function_crud.go - API: GET/PUT /functions/:name/envvars - handleGetFunction теперь возвращает env_vars в ответе - Frontend: блок env vars в edit modal (key=value + [×] + [+ Добавить]) - TF-функции показывают env vars в режиме read-only - Документация: doc/descriptions/function-envvars-flow.md - Версия v1.3.72 --- console/deploy/console.yaml | 2 +- console/internal/api/function_crud.go | 117 +++++++++++++++++++++- console/internal/api/handlers.go | 13 +++ console/ui/index.html | 15 ++- console/ui/js/functions.js | 103 +++++++++++++++++++ doc/descriptions/function-envvars-flow.md | 94 +++++++++++++++++ 6 files changed, 337 insertions(+), 7 deletions(-) create mode 100644 doc/descriptions/function-envvars-flow.md diff --git a/console/deploy/console.yaml b/console/deploy/console.yaml index 3f414e2..9ba2c3d 100644 --- a/console/deploy/console.yaml +++ b/console/deploy/console.yaml @@ -55,7 +55,7 @@ spec: serviceAccountName: fission-console containers: - name: console - image: naeel/fission-console:v1.3.71 + image: naeel/fission-console:v1.3.72 imagePullPolicy: Always ports: - containerPort: 8090 diff --git a/console/internal/api/function_crud.go b/console/internal/api/function_crud.go index afdb9a9..4ac689a 100644 --- a/console/internal/api/function_crud.go +++ b/console/internal/api/function_crud.go @@ -1,10 +1,12 @@ -// Package api — CRUD операции с функциями: чтение, удаление, обновление таймаута, логи. +// Package api — CRUD операции с функциями: чтение, удаление, обновление таймаута, логи, env vars. // // Этот файл содержит операции, не связанные с заменой кода/архива: -// - handleGetFunction — GET /functions/:name (детали: код, route, environment, source_type) -// - handleDeleteFunction — DELETE /functions/:name (каскадное удаление: триггеры, Package, S3) +// - handleGetFunction — GET /functions/:name (детали: код, route, environment, source_type) +// - handleDeleteFunction — DELETE /functions/:name (каскадное удаление: триггеры, Package, S3) // - handleUpdateFunctionTimeout — PUT /functions/:name/timeout (только таймаут, без замены кода) -// - handleGetFunctionLogs — GET /functions/:name/logs (логи пода через Kubernetes API) +// - handleGetFunctionLogs — GET /functions/:name/logs (логи пода через Kubernetes API) +// - handleGetFunctionEnvVars — GET /functions/:name/envvars (переменные окружения из CRD) +// - handlePutFunctionEnvVars — PUT /functions/:name/envvars (обновить env vars в CRD) // // Операции с кодом и архивом — в function_code.go и function_archive.go соответственно. // Вызов функции — в function_invoke.go. @@ -102,6 +104,7 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name "archive_filename": archiveFilename, "route": route, "methods": methods, + "env_vars": extractEnvVars(fn), "raw": fn.Object, }) } @@ -284,3 +287,109 @@ func (s *Server) handleGetFunctionLogs(w http.ResponseWriter, r *http.Request, n "logs": allLogs.String(), }) } + +// handleGetFunctionEnvVars возвращает переменные окружения функции из .spec.runtime.container.env +func (s *Server) handleGetFunctionEnvVars(w http.ResponseWriter, r *http.Request, name string) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + ns := s.userNS(r) + + fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + status := http.StatusBadGateway + if apierrors.IsNotFound(err) { + status = http.StatusNotFound + } + writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err)) + return + } + + envVars := extractEnvVars(fn) + writeAnyJSON(w, http.StatusOK, map[string]any{"env_vars": envVars}) +} + +// handlePutFunctionEnvVars обновляет переменные окружения функции в .spec.runtime.container.env +func (s *Server) handlePutFunctionEnvVars(w http.ResponseWriter, r *http.Request, name string) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + ns := s.userNS(r) + + var req struct { + EnvVars []map[string]string `json:"env_vars"` // [{name: "KEY", value: "VAL"}, ...] + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err)) + return + } + + // Валидация: имена переменных + for _, ev := range req.EnvVars { + k := ev["name"] + if k == "" { + writeJSONError(w, http.StatusBadRequest, "env var name cannot be empty") + return + } + } + + fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + status := http.StatusBadGateway + if apierrors.IsNotFound(err) { + status = http.StatusNotFound + } + writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err)) + return + } + + // Формируем список [{name: ..., value: ...}] для .spec.runtime.container.env + envList := make([]any, 0, len(req.EnvVars)) + for _, ev := range req.EnvVars { + envList = append(envList, map[string]any{ + "name": ev["name"], + "value": ev["value"], + }) + } + + // Устанавливаем .spec.runtime.container.env + if err := unstructured.SetNestedSlice(fn.Object, envList, "spec", "runtime", "container", "env"); err != nil { + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set env vars: %v", err)) + return + } + + // Обновляем аннотацию updated-at + now := time.Now().UTC().Format(time.RFC3339) + ann := fn.GetAnnotations() + if ann == nil { + ann = map[string]string{} + } + ann[functionUpdatedAtAnnotation] = now + fn.SetAnnotations(ann) + + if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function %q: %v", name, err)) + return + } + + writeAnyJSON(w, http.StatusOK, map[string]any{"updated": true, "count": len(req.EnvVars)}) +} + +// extractEnvVars читает .spec.runtime.container.env из Function CRD и возвращает [{name, value}, ...] +func extractEnvVars(fn *unstructured.Unstructured) []map[string]string { + raw, found, _ := unstructured.NestedSlice(fn.Object, "spec", "runtime", "container", "env") + if !found || len(raw) == 0 { + return []map[string]string{} + } + result := make([]map[string]string, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + k, _ := m["name"].(string) + v, _ := m["value"].(string) + if k != "" { + result = append(result, map[string]string{"name": k, "value": v}) + } + } + return result +} diff --git a/console/internal/api/handlers.go b/console/internal/api/handlers.go index 40caa26..896acc2 100644 --- a/console/internal/api/handlers.go +++ b/console/internal/api/handlers.go @@ -126,6 +126,19 @@ func (s *Server) handleFunctionsAction(w http.ResponseWriter, r *http.Request) { return } + // /functions/:name/envvars — переменные окружения + if len(parts) == 2 && parts[1] == "envvars" { + switch r.Method { + case http.MethodGet: + s.handleGetFunctionEnvVars(w, r, name) + case http.MethodPut: + s.handlePutFunctionEnvVars(w, r, name) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + return + } + http.NotFound(w, r) } diff --git a/console/ui/index.html b/console/ui/index.html index e6cf072..d162e79 100644 --- a/console/ui/index.html +++ b/console/ui/index.html @@ -102,7 +102,7 @@
NUBES
FISSION CONSOLE
-
v1.3.71
+
v1.3.72
@@ -389,6 +389,17 @@ style="display:none;margin-top:8px;padding:10px 12px;border-radius:6px;font-size:13px;line-height:1.5;white-space:pre-wrap;font-family:monospace;">
+ + +
+
Переменные окружения
+ +
+ +
+
@@ -498,7 +509,7 @@
- v1.3.71 + v1.3.72
diff --git a/console/ui/js/functions.js b/console/ui/js/functions.js index ac4daf4..dbed988 100644 --- a/console/ui/js/functions.js +++ b/console/ui/js/functions.js @@ -191,6 +191,11 @@ async function openEdit(name) { var isTf = /^tf-/.test(name) || /go[-_]env/.test(fn.environment || ''); warnEl.style.display = isTf ? 'block' : 'none'; } + + // Env vars — отрисовать блок; TF-функции — только чтение + var isTfFn = /^tf-/.test(name) || /go[-_]env/.test(fn.environment || ''); + renderEnvVars('e', fn.env_vars || [], isTfFn); + document.getElementById('edit-modal').classList.add('open'); } catch (e) { showStatus('Ошибка загрузки функции: ' + e.message, 'err'); @@ -238,6 +243,16 @@ async function submitEdit() { } await syncScheduleForFunction(name, 'e'); + + // Сохраняем env vars (только если не TF-функция) + var isTfFn = /^tf-/.test(name); + if (!isTfFn) { + var envVars = collectEnvVars('e'); + await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/envvars', 'PUT', { + env_vars: envVars + }); + } + closeEdit(); progress.stop('Код обновлён: ' + name, 'ok'); await reloadAll(); @@ -260,3 +275,91 @@ async function removeFn(name) { progress.stop('Ошибка удаления: ' + e.message, 'err'); } } + +// --- Env Vars UI --- + +// renderEnvVars отрисовывает список переменных окружения в блоке prefix-envvars-list +// vars: [{name: "KEY", value: "VAL"}, ...] +// readOnly: true для TF-функций +function renderEnvVars(prefix, vars, readOnly) { + var list = document.getElementById(prefix + '-envvars-list'); + var addBtn = document.getElementById(prefix + '-add-envvar-btn'); + var tfWarn = document.getElementById(prefix + '-tf-env-warn'); + if (!list) return; + list.innerHTML = ''; + if (readOnly) { + if (tfWarn) tfWarn.style.display = ''; + if (addBtn) addBtn.style.display = 'none'; + } else { + if (tfWarn) tfWarn.style.display = 'none'; + if (addBtn) addBtn.style.display = ''; + } + (vars || []).forEach(function(ev, idx) { + list.appendChild(makeEnvVarRow(prefix, ev.name || '', ev.value || '', readOnly, idx)); + }); +} + +// makeEnvVarRow создаёт одну строку key=value с кнопкой удаления +function makeEnvVarRow(prefix, key, val, readOnly, idx) { + var row = document.createElement('div'); + row.style.cssText = 'display:flex; gap:6px; align-items:center;'; + row.dataset.envIdx = idx; + + var kInput = document.createElement('input'); + kInput.placeholder = 'KEY'; + kInput.value = key; + kInput.disabled = readOnly; + kInput.style.cssText = 'flex:1; font-size:12px; font-family:monospace;'; + kInput.dataset.envKey = '1'; + + var vInput = document.createElement('input'); + vInput.placeholder = 'value'; + vInput.value = val; + vInput.disabled = readOnly; + vInput.style.cssText = 'flex:2; font-size:12px; font-family:monospace;'; + vInput.dataset.envVal = '1'; + + row.appendChild(kInput); + + var eq = document.createElement('span'); + eq.textContent = '='; + eq.style.cssText = 'color:var(--text-secondary); font-family:monospace;'; + row.appendChild(eq); + row.appendChild(vInput); + + if (!readOnly) { + var delBtn = document.createElement('button'); + delBtn.textContent = '×'; + delBtn.className = 'btn ghost'; + delBtn.style.cssText = 'padding:2px 8px; font-size:14px; line-height:1;'; + delBtn.title = 'Удалить переменную'; + delBtn.onclick = function() { row.remove(); }; + row.appendChild(delBtn); + } + + return row; +} + +// addEnvVarRow добавляет пустую строку в список env vars +function addEnvVarRow(prefix) { + var list = document.getElementById(prefix + '-envvars-list'); + if (!list) return; + var idx = list.children.length; + list.appendChild(makeEnvVarRow(prefix, '', '', false, idx)); +} + +// collectEnvVars читает текущие значения из DOM и возвращает [{name, value}, ...] +function collectEnvVars(prefix) { + var list = document.getElementById(prefix + '-envvars-list'); + if (!list) return []; + var result = []; + var rows = list.querySelectorAll('div[data-env-idx]'); + rows.forEach(function(row) { + var k = row.querySelector('[data-env-key]'); + var v = row.querySelector('[data-env-val]'); + var key = k ? k.value.trim() : ''; + var val = v ? v.value : ''; + if (key) result.push({name: key, value: val}); + }); + return result; +} diff --git a/doc/descriptions/function-envvars-flow.md b/doc/descriptions/function-envvars-flow.md new file mode 100644 index 0000000..41a3605 --- /dev/null +++ b/doc/descriptions/function-envvars-flow.md @@ -0,0 +1,94 @@ +# Переменные окружения функций (env vars) + +## Хранение + +Env vars хранятся прямо в Function CRD в поле `.spec.runtime.container.env`: + +```yaml +apiVersion: fission.io/v1 +kind: Function +metadata: + name: my-fn + namespace: c479c9de005f +spec: + runtime: + container: + env: + - name: DB_HOST + value: "localhost" + - name: API_KEY + value: "secret123" +``` + +**Почему CRD, а не ConfigMap/PostgreSQL:** +- Env vars — часть конфигурации функции, а не статистика +- CRD хранится в etcd — единственном хранилище состояния Kubernetes +- При удалении функции env vars удаляются автоматически (нет orphan-объектов) +- Не нужен дополнительный RBAC для ConfigMap +- Fission executor сам передаёт `.spec.runtime.container.env` в pod + +## API + +### GET /console/api/functions/:name/envvars +Возвращает список переменных: +```json +{ + "env_vars": [ + {"name": "DB_HOST", "value": "localhost"}, + {"name": "API_KEY", "value": "secret123"} + ] +} +``` + +### PUT /console/api/functions/:name/envvars +Принимает полный список (полная замена, не merge): +```json +{ + "env_vars": [ + {"name": "DB_HOST", "value": "new-host"}, + {"name": "NEW_VAR", "value": "val"} + ] +} +``` +Пустой массив `[]` — удалить все переменные. + +### GET /console/api/functions/:name +Поле `env_vars` включено в стандартный ответ (чтобы edit modal не делал отдельный запрос). + +## Frontend (edit modal) + +- Блок "Переменные окружения" появляется в нижней части edit modal при каждом открытии +- Каждая переменная — строка: input[KEY] = input[value] [×] +- Кнопка "+ Добавить переменную" добавляет пустую строку +- Сохранение происходит вместе с остальными полями (Сохранить) +- После сохранения кода/архива вызывается PUT /envvars с текущим состоянием блока + +## Terraform-функции (read-only) + +Функции с именем `tf-*` или с go-env (Terraform-управляемые) показывают блок env vars в режиме **только чтения**: +- Все input-поля `disabled` +- Кнопки `[×]` и `[+ Добавить]` скрыты +- Предупреждение: "⚠️ Функция управляется Terraform — переменные только для чтения" +- PUT /envvars **не вызывается** при сохранении TF-функций + +Изменить переменные TF-функции можно только через Terraform (`terraform apply`). + +## Backend (Go) + +**Файл:** `console/internal/api/function_crud.go` + +- `handleGetFunctionEnvVars` — читает `.spec.runtime.container.env` через dynamic client +- `handlePutFunctionEnvVars` — PATCH Function CRD, обновляет `.spec.runtime.container.env` +- `extractEnvVars` — вспомогательная функция, читает из unstructured в `[]map[string]string` + +**Роутинг:** `console/internal/api/handlers.go` +``` +GET /console/api/functions/:name/envvars → handleGetFunctionEnvVars +PUT /console/api/functions/:name/envvars → handlePutFunctionEnvVars +``` + +## Ограничения (MVP) + +- Секретные переменные (пароли, токены) хранятся в открытом виде в etcd +- Для sensitive данных в будущем нужно добавить поддержку Secret (valueFrom.secretKeyRef) +- Нет валидации имён переменных по RFC (разрешены любые непустые строки)