feat: переменные окружения функций (env vars в CRD)

- 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
This commit is contained in:
“Naeel”
2026-05-09 06:50:52 +04:00
parent fb469d9c5b
commit 1fa92e069f
6 changed files with 337 additions and 7 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ spec:
serviceAccountName: fission-console serviceAccountName: fission-console
containers: containers:
- name: console - name: console
image: naeel/fission-console:v1.3.71 image: naeel/fission-console:v1.3.72
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 8090 - containerPort: 8090
+113 -4
View File
@@ -1,10 +1,12 @@
// Package api — CRUD операции с функциями: чтение, удаление, обновление таймаута, логи. // Package api — CRUD операции с функциями: чтение, удаление, обновление таймаута, логи, env vars.
// //
// Этот файл содержит операции, не связанные с заменой кода/архива: // Этот файл содержит операции, не связанные с заменой кода/архива:
// - handleGetFunction — GET /functions/:name (детали: код, route, environment, source_type) // - handleGetFunction — GET /functions/:name (детали: код, route, environment, source_type)
// - handleDeleteFunction — DELETE /functions/:name (каскадное удаление: триггеры, Package, S3) // - handleDeleteFunction — DELETE /functions/:name (каскадное удаление: триггеры, Package, S3)
// - handleUpdateFunctionTimeout — PUT /functions/:name/timeout (только таймаут, без замены кода) // - 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_code.go и function_archive.go соответственно.
// Вызов функции — в function_invoke.go. // Вызов функции — в function_invoke.go.
@@ -102,6 +104,7 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
"archive_filename": archiveFilename, "archive_filename": archiveFilename,
"route": route, "route": route,
"methods": methods, "methods": methods,
"env_vars": extractEnvVars(fn),
"raw": fn.Object, "raw": fn.Object,
}) })
} }
@@ -284,3 +287,109 @@ func (s *Server) handleGetFunctionLogs(w http.ResponseWriter, r *http.Request, n
"logs": allLogs.String(), "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
}
+13
View File
@@ -126,6 +126,19 @@ func (s *Server) handleFunctionsAction(w http.ResponseWriter, r *http.Request) {
return 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) http.NotFound(w, r)
} }
+13 -2
View File
@@ -102,7 +102,7 @@
<div class="nubes">NUBES</div> <div class="nubes">NUBES</div>
<div class="product">FISSION CONSOLE</div> <div class="product">FISSION CONSOLE</div>
</div> </div>
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.71</div> <div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.72</div>
</div> </div>
<div class="row" style="margin:0;"> <div class="row" style="margin:0;">
<button class="btn ghost" onclick="reloadAll()">Refresh</button> <button class="btn ghost" onclick="reloadAll()">Refresh</button>
@@ -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;"> 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;">
</div> </div>
</div> </div>
<!-- Переменные окружения -->
<div style="margin-top:16px;">
<div style="font-weight:600; margin-bottom:8px; font-size:13px;">Переменные окружения</div>
<div id="e-tf-env-warn" style="display:none; margin-bottom:8px; padding:6px 10px; background:#553300; color:#ffa; border-radius:4px; font-size:12px;">
⚠️ Функция управляется Terraform — переменные только для чтения
</div>
<div id="e-envvars-list" style="display:flex; flex-direction:column; gap:4px; margin-bottom:8px;"></div>
<button id="e-add-envvar-btn" class="btn ghost" style="font-size:12px;" onclick="addEnvVarRow('e')">+ Добавить переменную</button>
</div>
<div class="actions"> <div class="actions">
<button class="btn ghost" onclick="closeEdit()">Отмена</button> <button class="btn ghost" onclick="closeEdit()">Отмена</button>
<button id="e-submit" class="btn" onclick="submitEdit()">Сохранить</button> <button id="e-submit" class="btn" onclick="submitEdit()">Сохранить</button>
@@ -498,7 +509,7 @@
</div> </div>
<div class="actions" style="justify-content:space-between; align-items:center;"> <div class="actions" style="justify-content:space-between; align-items:center;">
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.71</span> <span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.72</span>
<button class="btn ghost" onclick="closeHelp()">Закрыть</button> <button class="btn ghost" onclick="closeHelp()">Закрыть</button>
</div> </div>
</div> </div>
+103
View File
@@ -191,6 +191,11 @@ async function openEdit(name) {
var isTf = /^tf-/.test(name) || /go[-_]env/.test(fn.environment || ''); var isTf = /^tf-/.test(name) || /go[-_]env/.test(fn.environment || '');
warnEl.style.display = isTf ? 'block' : 'none'; 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'); document.getElementById('edit-modal').classList.add('open');
} catch (e) { } catch (e) {
showStatus('Ошибка загрузки функции: ' + e.message, 'err'); showStatus('Ошибка загрузки функции: ' + e.message, 'err');
@@ -238,6 +243,16 @@ async function submitEdit() {
} }
await syncScheduleForFunction(name, 'e'); 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(); closeEdit();
progress.stop('Код обновлён: ' + name, 'ok'); progress.stop('Код обновлён: ' + name, 'ok');
await reloadAll(); await reloadAll();
@@ -260,3 +275,91 @@ async function removeFn(name) {
progress.stop('Ошибка удаления: ' + e.message, 'err'); 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;
}
+94
View File
@@ -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 (разрешены любые непустые строки)