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;">
+
+
+
+
Переменные окружения
+
+ ⚠️ Функция управляется Terraform — переменные только для чтения
+
+
+
+
+
@@ -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 (разрешены любые непустые строки)