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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+13
-2
@@ -102,7 +102,7 @@
|
||||
<div class="nubes">NUBES</div>
|
||||
<div class="product">FISSION CONSOLE</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 class="row" style="margin:0;">
|
||||
<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;">
|
||||
</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">
|
||||
<button class="btn ghost" onclick="closeEdit()">Отмена</button>
|
||||
<button id="e-submit" class="btn" onclick="submitEdit()">Сохранить</button>
|
||||
@@ -498,7 +509,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (разрешены любые непустые строки)
|
||||
Reference in New Issue
Block a user