fix: document cron router auth chain

This commit is contained in:
Naeel
2026-04-29 08:39:19 +03:00
parent a534fddd2c
commit 35fd9ec40e
19 changed files with 1903 additions and 15 deletions
+40
View File
@@ -0,0 +1,40 @@
# Fission timetrigger CLI
Официальный CLI-раздел для cron-триггеров в Fission использует команду `fission timetrigger`.
## Команды
### `fission timetrigger`
Групповая команда для управления time triggers.
Подкоманды из официальной справки:
- `fission timetrigger create` — создать time trigger
- `fission timetrigger delete` — удалить time trigger
- `fission timetrigger list` — показать список time triggers
- `fission timetrigger showschedule` — показать ближайшие запуски для cron-выражения
- `fission timetrigger update` — обновить time trigger
### `fission timetrigger create`
Создает time trigger.
Основные параметры:
- `--name` — имя триггера
- `--function` — имя функции
- `--cron` — cron-спецификация
- `--method` — HTTP method для вызова функции
- `--subpath` — под-путь внутри функции, если функция поддерживает routing
- `--spec` — сохранить spec вместо создания в кластере
- `--dry` — показать сгенерированные spec-файлы
### `fission timetrigger showschedule`
Показывает ближайшие моменты запуска для cron-строки.
Параметры:
- `--cron` — cron-спецификация
- `--round` — количество следующих запусков для вывода
## Важные детали из документации
- Cron-строка может быть в формате с шестью полями: секунды, минуты, часы, день месяца, месяц, день недели.
- Поддерживаются читаемые форматы вроде `@every 5m` и `@hourly`.
- При отображении расписания используется время сервера, а не локальное время клиента.
+47
View File
@@ -0,0 +1,47 @@
# Fission TimeTrigger CRD
В официальной CRD Reference Fission cron-триггер называется `TimeTrigger`.
## Kind
- `apiVersion`: `fission.io/v1`
- `kind`: `TimeTrigger`
## Назначение
`TimeTrigger` запускает функцию по расписанию, заданному cron-строкой.
## Spec
Согласно официальной схеме, у `TimeTriggerSpec` есть поля:
- `cron` — cron schedule
- `functionref` — ссылка на функцию
- `method` — HTTP method для вызова функции, по умолчанию `POST`
- `subpath` — под-путь для маршрутизации внутри функции, по умолчанию `/`
## FunctionReference
`functionref` содержит ссылку на функцию:
- `type` — тип ссылки, для time trigger используется `name`
- `name` — имя функции
## Минимальный пример
```yaml
apiVersion: fission.io/v1
kind: TimeTrigger
metadata:
name: cron-job
namespace: fission-function
spec:
cron: "*/5 * * * *"
functionref:
type: name
name: hello
```
## Что важно помнить
- `TimeTrigger` вызывает функцию через HTTP-вызов.
- Если функция внутри сама поддерживает routing, `subpath` помогает выбрать нужный маршрут.
- `method` можно использовать, если триггер должен дергать функцию не `POST`, а `GET`, `PUT`, `DELETE` или `HEAD`.
+39
View File
@@ -0,0 +1,39 @@
# Fission cron examples
Ниже примеры cron-расписаний из официальной документации Fission.
## Примеры cron строк
### Каждые 30 минут
```bash
fission timetrigger create --name halfhourly --function hello --cron "0 */30 * * * *"
```
### Каждую минуту
```bash
fission timetrigger create --name minute --function hello --cron "@every 1m"
```
### Проверка расписания
```bash
fission timetrigger showschedule --cron "0 30 * * * *" --round 5
```
## Как читать cron в Fission
Официальная CLI-справка указывает, что поля cron идут так:
- секунды
- минуты
- часы
- день месяца
- месяц
- день недели
## Практический вывод
Для Fission cron-триггеров обычно важны три вещи:
- имя триггера
- имя функции
- cron-выражение
Если функция поддерживает routing, дополнительно можно задавать `subpath` и `method`.
+46
View File
@@ -0,0 +1,46 @@
# Fission cron: наши выводы и факты
Короткий локальный конспект, чтобы не искать повторно по сторонней документации.
## Что делает оригинальный Fission
- Cron в Fission запускается штатным механизмом `TimeTrigger` / `timetrigger`.
- `timer` — отдельный controller-под, который живет постоянно, пока deployment поднят.
- `timer` смотрит только те namespace-ы, которые ему явно заданы при старте.
- Когда наступает время, `timer` инициирует обычный Fission invoke через `router`.
- Console не участвует в выполнении cron после создания/обновления `TimeTrigger`.
## Что важно для multi-namespace
- Да, в Fission изначально заложена работа с несколькими namespace-ами.
- Но это не "авто-перебор всего кластера".
- Нужен явный watch-list, обычно через `FISSION_RESOURCE_NAMESPACES`.
- Если namespace не входит в этот список, `timer` его `TimeTrigger` не увидит.
## Что делает наша console
- Console создает или обновляет `TimeTrigger` в namespace пользователя.
- В `TimeTrigger` задаются:
- `spec.cron`
- `spec.functionref`
- `spec.method`
- `spec.subpath`
- После этого дальнейший запуск полностью делает Fission.
- Console только управляет CRD и показывает состояние/метрики.
## Что мы выяснили в этом проекте
- Основная проблема была не в cron-механизме Fission как таковом, а в visibility namespaces для `timer`.
- `timer` сначала смотрел только `default`, поэтому user namespace был невидим.
- Дополнительно, history на `/cron` хранится в памяти процесса console, поэтому rollout сбрасывает накопленные снимки.
## Практический вывод
- Для cron важно не только создать `TimeTrigger`, но и убедиться, что `timer` watch-ит namespace пользователя.
- Если `/cron` пустой после rollout, это может быть:
- `timer` не видит namespace
- `TimeTrigger` не создан
- invoke не дошел до функции
- snapshot не сохранился в `cron_metrics`
- Console не запускает shell-команды в Kubernetes и не исполняет cron сама.
- Console только создает CRD, а дальше работает штатный Fission controller path.
+8
View File
@@ -0,0 +1,8 @@
# CRON glossary
- `TimeTrigger` — CRD-объект Fission для запуска функции по расписанию.
- `timetrigger` — CLI-команда для управления time triggers.
- `cron` — строка расписания, по которой Fission планирует вызов функции.
- `functionref` — ссылка на функцию, которую должен вызывать триггер.
- `method` — HTTP-метод вызова функции.
- `subpath` — путь внутри функции, если она поддерживает внутренний routing.
+159
View File
@@ -0,0 +1,159 @@
# GPT 5.4 handoff: Fission CRON plan
## Goal
Составить только план действий по теме Fission cron / time triggers. Не писать реализацию сейчас. Не раздувать ответ. Нужен прагматичный, короткий, пошаговый план.
## What is the topic
В Fission cron-триггеры в документации и CRD называются `TimeTrigger`, а в CLI — `fission timetrigger`.
## Official docs used as source of truth
- https://fission.io/docs/usage/triggers/
- https://fission.io/docs/usage/triggers/timer/
- https://fission.io/docs/reference/crd-reference/
- https://fission.io/docs/reference/fission-cli/fission_timetrigger/
- https://fission.io/docs/reference/fission-cli/fission_timetrigger_create/
- https://fission.io/docs/reference/fission-cli/fission_timetrigger_showschedule/
## Facts to keep in mind
- There is no dedicated `/docs/cron/` page on the official site; relevant official page is `Timer Triggers`.
- Timer triggers run functions on a schedule.
- Cron spec supports 6 fields: seconds, minutes, hours, day of month, month, day of week.
- Readable forms are supported too: `@every 5m`, `@hourly`.
- Schedule output should use server time, not client time.
## CRD facts
- `apiVersion`: `fission.io/v1`
- `kind`: `TimeTrigger`
- `spec.cron` — cron expression
- `spec.functionref` — function reference
- `spec.method` — HTTP method, default `POST`
- `spec.subpath` — subpath, default `/`
- `functionref.type` is `name`
- `functionref.name` is the function name
## CLI facts
### `fission timetrigger`
Subcommands from official docs:
- `create`
- `delete`
- `list`
- `showschedule`
- `update`
### `fission timetrigger create`
Key flags:
- `--name`
- `--function`
- `--cron`
- `--method`
- `--subpath`
- `--spec`
- `--dry`
### `fission timetrigger showschedule`
Key flags:
- `--cron`
- `--round`
## Repo facts
Current repo has a local CRON folder with:
- `CRON/README.md`
- `CRON/CLI.md`
- `CRON/CRD.md`
- `CRON/EXAMPLES.md`
- `CRON/GLOSSARY.md`
Relevant console routes currently visible in code:
- `GET /api/timetriggers`
- `GET /console/api/timetriggers`
This means current console code clearly exposes listing for timetriggers; do not assume create/delete UI is already implemented unless verified separately.
## What the plan should optimize for
- shortest path to useful outcome
- no token waste
- no speculative implementation details
- only actions that are justified by the facts above
## Recommended output format for GPT 5.4
1. One-line conclusion
2. Short numbered plan
3. Risks or unknowns, only if they block the plan
4. No extra explanation
## Plan for mini
1. Зафиксировать целевой результат: что именно нужно сделать с cron в этом проекте.
Нужно выбрать одно из трёх:
`документация`, `console API CRUD`, `UI CRUD/просмотр`, либо полный путь поэтапно.
2. Принять официальный термин как базовый.
В коде и плане опираться на `TimeTrigger`/`timetrigger`, а не на абстрактное “cron”, чтобы не путать CLI, CRD и UI.
3. Разделить текущее состояние на “уже есть” и “надо сделать”.
Уже есть:
`официальная сводка`, `CRON docs`, `GET /api/timetriggers`, `GET /console/api/timetriggers`.
Проверить отдельно:
есть ли `POST/DELETE/UPDATE` для timetriggers в console API, модель запроса, UI-форма, UI-delete.
4. Если цель именно реализация, сначала закрыть backend API.
Минимальный набор:
`CreateTimeTriggerRequest`,
`POST /console/api/timetriggers`,
`DELETE /console/api/timetriggers/:name`,
при необходимости `PUT`.
Логика должна строить CRD `fission.io/v1`, `kind: TimeTrigger` с полями:
`cron`, `functionref`, `method`, `subpath`.
5. После backend закрыть валидацию.
Нужно валидировать:
имя,
существование функции,
непустой `cron`,
при необходимости `method`,
дефолты для `method=POST`, `subpath=/`.
Отдельно не гадать формат cron вручную, если можно отдать это на Fission/его контракт.
6. Затем делать UI только после подтверждённого backend.
Минимум:
список timetriggers,
создание,
удаление,
опционально редактирование.
Если UI не нужен сейчас, не тратить на него время.
7. Тестировать в том же порядке.
Сначала unit/integration на API builder TimeTrigger.
Потом живой сценарий:
создать функцию,
создать timetrigger,
проверить list,
проверить delete.
`showschedule` использовать как вспомогательную проверку cron-строк, а не как часть console API.
8. Документацию держать синхронно с реализацией.
Обновлять только новую папку CRON и соседние материалы, не размазывая контекст по старым документам без необходимости.
9. Не делать лишнего на первом проходе.
Не трогать:
Terraform provider,
сложный scheduler,
расширенные cron-валидаторы,
полный UI-редизайн,
пока не готов минимальный CRUD по TimeTrigger.
10. Рабочий порядок для mini:
сначала поиск фактов в коде,
потом backend routes/models/handlers,
потом тесты,
потом UI,
потом короткая проверка живым сценарием.
+126
View File
@@ -0,0 +1,126 @@
# Python cron function template
Эту функцию можно создать вручную в console UI и привязать к `TimeTrigger`.
Она собирает базовые метрики системы, отдает JSON и сразу отправляет snapshot в страницу cron dashboard.
## Что нужно задать в UI
- `Route`: например `/system-metrics`
- `Methods`: `GET,POST`
- `Cron`: например `*/5 * * * *`
- `Entrypoint`: `main.main`
## Код
```python
import json
import os
import socket
import urllib.request
from datetime import datetime, timezone
def read_meminfo():
mem = {}
try:
with open('/proc/meminfo', 'r', encoding='utf-8') as fh:
for line in fh:
if ':' not in line:
continue
key, value = line.split(':', 1)
parts = value.strip().split()
if not parts:
continue
try:
mem[key] = float(parts[0]) / 1024.0 # kB -> MB
except ValueError:
continue
except OSError:
pass
return mem
def read_uptime_sec():
try:
with open('/proc/uptime', 'r', encoding='utf-8') as fh:
return float(fh.read().split()[0])
except Exception:
return 0.0
def read_disk_gb(path='/'):
st = os.statvfs(path)
total = (st.f_blocks * st.f_frsize) / (1024 ** 3)
free = (st.f_bavail * st.f_frsize) / (1024 ** 3)
used = total - free
return total, free, used
def payload():
mem = read_meminfo()
total = mem.get('MemTotal', 0.0)
free = mem.get('MemFree', 0.0)
available = mem.get('MemAvailable', free)
used = max(0.0, total - free) if total else 0.0
mem_percent = (used / total) * 100.0 if total else 0.0
try:
load1, load5, load15 = os.getloadavg()
except (AttributeError, OSError):
load1 = load5 = load15 = 0.0
disk_total, disk_free, disk_used = read_disk_gb('/')
return {
'timestamp': datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z'),
'source': os.getenv('CRON_SOURCE', 'python-cron'),
'hostname': socket.gethostname(),
'status': 'ok',
'memory_total_mb': round(total, 2),
'memory_free_mb': round(free, 2),
'memory_available_mb': round(available, 2),
'memory_used_mb': round(used, 2),
'memory_percent': round(mem_percent, 2),
'cpu_load_1m': round(load1, 2),
'cpu_load_5m': round(load5, 2),
'cpu_load_15m': round(load15, 2),
'disk_total_gb': round(disk_total, 2),
'disk_free_gb': round(disk_free, 2),
'disk_used_gb': round(disk_used, 2),
'uptime_sec': round(read_uptime_sec(), 2),
}
def push_snapshot(data):
target = os.getenv('CRON_TARGET_URL', 'http://fission-console.fission.svc.cluster.local/cron/api/metrics')
req = urllib.request.Request(
target,
data=json.dumps(data).encode('utf-8'),
method='POST',
headers={'Content-Type': 'application/json'},
)
token = os.getenv('CRON_TOKEN', '').strip()
if token:
req.add_header('X-Cron-Token', token)
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.read().decode('utf-8', errors='ignore')
def main():
data = payload()
try:
push_snapshot(data)
except Exception as exc:
data['push_error'] = str(exc)
return {
'status': 200,
'headers': {'Content-Type': 'application/json'},
'body': json.dumps(data, ensure_ascii=False),
}
```
## Что важно
- `CRON_TARGET_URL` можно оставить по умолчанию, если console доступна по публичному URL.
- Если захочешь защитить ingest, задай `CRON_TOKEN` в функции и такой же `X-Cron-Token` на стороне console.
- Функция не требует внешних библиотек.
+22
View File
@@ -0,0 +1,22 @@
# CRON в Fission
Эта папка собрана по официальной документации Fission и посвящена time-based triggers, которые в CLI называются `timetrigger`, а в CRD — `TimeTrigger`.
## Кратко
Fission Timer Trigger запускает функцию по cron-расписанию. Это не HTTP-trigger и не message-queue trigger, а отдельный механизм запуска по времени.
Официальные страницы, на которых основана сводка:
- https://fission.io/docs/usage/triggers/
- https://fission.io/docs/usage/triggers/timer/
- https://fission.io/docs/reference/crd-reference/
- https://fission.io/docs/reference/fission-cli/fission_timetrigger/
- https://fission.io/docs/reference/fission-cli/fission_timetrigger_create/
- https://fission.io/docs/reference/fission-cli/fission_timetrigger_showschedule/
## Что здесь описано
- [CLI](CLI.md) — команды `fission timetrigger`
- [CRD](CRD.md) — объект `TimeTrigger` и его поля
- [Examples](EXAMPLES.md) — примеры cron-расписаний
- [Glossary](GLOSSARY.md) — короткие определения терминов
+11 -1
View File
@@ -12,6 +12,9 @@ rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "update", "patch"]
- apiGroups: ["fission.io"]
resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"]
verbs: ["get", "list", "create", "update", "patch", "delete"]
@@ -49,7 +52,7 @@ spec:
serviceAccountName: fission-console
containers:
- name: console
image: naeel/fission-console:v1.3.3
image: naeel/fission-console:v1.3.8
ports:
- containerPort: 8090
env:
@@ -140,6 +143,13 @@ spec:
name: fission-console
port:
number: 8090
- path: /cron
pathType: Prefix
backend:
service:
name: fission-console
port:
number: 8090
tls:
- hosts:
- fission.kube5s.ru
+203
View File
@@ -0,0 +1,203 @@
package api
import (
"encoding/json"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
type CronMetric struct {
Timestamp string `json:"timestamp"`
Source string `json:"source"`
Hostname string `json:"hostname,omitempty"`
Status string `json:"status,omitempty"`
MemoryTotalMB float64 `json:"memory_total_mb,omitempty"`
MemoryFreeMB float64 `json:"memory_free_mb,omitempty"`
MemoryAvailMB float64 `json:"memory_available_mb,omitempty"`
MemoryUsedMB float64 `json:"memory_used_mb,omitempty"`
MemoryPercent float64 `json:"memory_percent,omitempty"`
CpuLoad1m float64 `json:"cpu_load_1m,omitempty"`
CpuLoad5m float64 `json:"cpu_load_5m,omitempty"`
CpuLoad15m float64 `json:"cpu_load_15m,omitempty"`
DiskTotalGB float64 `json:"disk_total_gb,omitempty"`
DiskFreeGB float64 `json:"disk_free_gb,omitempty"`
DiskUsedGB float64 `json:"disk_used_gb,omitempty"`
UptimeSec float64 `json:"uptime_sec,omitempty"`
Notes string `json:"notes,omitempty"`
}
var (
cronMetricMu sync.RWMutex
cronMetricHistory []CronMetric
cronMetricMaxKeep = 240
)
func (s *Server) handleCronMetrics(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
cronMetricMu.RLock()
history := append([]CronMetric(nil), cronMetricHistory...)
cronMetricMu.RUnlock()
latest := CronMetric{}
if len(history) > 0 {
latest = history[len(history)-1]
}
writeAnyJSON(w, http.StatusOK, map[string]any{
"count": len(history),
"latest": latest,
"history": history,
})
case http.MethodPost:
if !allowCronIngest(r) {
writeJSONError(w, http.StatusUnauthorized, "invalid cron token")
return
}
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid json payload")
return
}
sample := normalizeCronMetric(payload)
cronMetricMu.Lock()
cronMetricHistory = append(cronMetricHistory, sample)
if len(cronMetricHistory) > cronMetricMaxKeep {
cronMetricHistory = append([]CronMetric(nil), cronMetricHistory[len(cronMetricHistory)-cronMetricMaxKeep:]...)
}
count := len(cronMetricHistory)
cronMetricMu.Unlock()
writeAnyJSON(w, http.StatusOK, map[string]any{"ok": true, "stored": count, "latest": sample})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func allowCronIngest(r *http.Request) bool {
expected := strings.TrimSpace(os.Getenv("CRON_TOKEN"))
if expected == "" {
return true
}
return strings.TrimSpace(r.Header.Get("X-Cron-Token")) == expected
}
func normalizeCronMetric(payload map[string]any) CronMetric {
now := time.Now().UTC().Format(time.RFC3339)
metric := CronMetric{
Timestamp: now,
Source: textValue(payload, "source", "function", "name", "host", "hostname", "instance"),
Hostname: textValue(payload, "hostname", "host", "node", "instance"),
Status: textValue(payload, "status"),
Notes: textValue(payload, "notes", "note", "message"),
}
if metric.Source == "" {
metric.Source = "cron"
}
metric.MemoryTotalMB = numberValue(payload, "memory_total_mb", "total_memory_mb", "mem_total_mb", "mem_total")
metric.MemoryFreeMB = numberValue(payload, "memory_free_mb", "free_memory_mb", "mem_free_mb", "free_mb")
metric.MemoryAvailMB = numberValue(payload, "memory_available_mb", "available_memory_mb", "mem_available_mb", "avail_memory_mb")
metric.MemoryUsedMB = numberValue(payload, "memory_used_mb", "used_memory_mb", "mem_used_mb", "used_mb")
metric.MemoryPercent = numberValue(payload, "memory_percent", "mem_percent", "memory_usage_percent")
if metric.MemoryUsedMB == 0 && metric.MemoryTotalMB > 0 && metric.MemoryFreeMB > 0 {
metric.MemoryUsedMB = metric.MemoryTotalMB - metric.MemoryFreeMB
}
if metric.MemoryAvailMB == 0 && metric.MemoryFreeMB > 0 {
metric.MemoryAvailMB = metric.MemoryFreeMB
}
if metric.MemoryPercent == 0 && metric.MemoryTotalMB > 0 && metric.MemoryUsedMB > 0 {
metric.MemoryPercent = (metric.MemoryUsedMB / metric.MemoryTotalMB) * 100
}
metric.CpuLoad1m = numberValue(payload, "cpu_load_1m", "loadavg_1m", "load_1m", "load1")
metric.CpuLoad5m = numberValue(payload, "cpu_load_5m", "loadavg_5m", "load_5m", "load5")
metric.CpuLoad15m = numberValue(payload, "cpu_load_15m", "loadavg_15m", "load_15m", "load15")
metric.DiskTotalGB = numberValue(payload, "disk_total_gb", "total_disk_gb", "disk_total_mb", "disk_total")
metric.DiskFreeGB = numberValue(payload, "disk_free_gb", "free_disk_gb", "disk_free_mb", "disk_free")
metric.DiskUsedGB = numberValue(payload, "disk_used_gb", "used_disk_gb", "disk_used_mb", "disk_used")
if metric.DiskUsedGB == 0 && metric.DiskTotalGB > 0 && metric.DiskFreeGB > 0 {
metric.DiskUsedGB = metric.DiskTotalGB - metric.DiskFreeGB
}
if uptime := numberValue(payload, "uptime_sec", "uptime", "uptime_seconds"); uptime > 0 {
metric.UptimeSec = uptime
}
if ts := textValue(payload, "timestamp", "ts", "collected_at"); ts != "" {
metric.Timestamp = ts
}
return metric
}
func textValue(payload map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := payload[key]; ok {
text := strings.TrimSpace(toString(value))
if text != "" {
return text
}
}
}
return ""
}
func numberValue(payload map[string]any, keys ...string) float64 {
for _, key := range keys {
if value, ok := payload[key]; ok {
if number, ok := toFloat64(value); ok {
return number
}
}
}
return 0
}
func toString(value any) string {
switch v := value.(type) {
case string:
return v
case []byte:
return string(v)
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
case float32:
return strconv.FormatFloat(float64(v), 'f', -1, 32)
case int:
return strconv.FormatInt(int64(v), 10)
case int64:
return strconv.FormatInt(v, 10)
case json.Number:
return v.String()
default:
return ""
}
}
func toFloat64(value any) (float64, bool) {
switch v := value.(type) {
case float64:
return v, true
case float32:
return float64(v), true
case int:
return float64(v), true
case int64:
return float64(v), true
case json.Number:
f, err := v.Float64()
return f, err == nil
case string:
if strings.TrimSpace(v) == "" {
return 0, false
}
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
if err == nil {
return n, true
}
return 0, false
default:
return 0, false
}
}
+19 -2
View File
@@ -654,7 +654,7 @@ func (s *Server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, na
defer cancel()
// Ищем HTTPTrigger чтобы получить реальный URL и метод
invokeURL := fmt.Sprintf("%s/fission-function/v2/functions/%s", s.routerURL, name)
invokeURL := buildInternalInvokeURL(s.routerURL, ns, name)
invokeMethod := http.MethodPost
triggers, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if err == nil {
@@ -732,6 +732,13 @@ func (s *Server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, na
})
}
func buildInternalInvokeURL(routerURL, namespace, functionName string) string {
if namespace == "default" || namespace == "" {
return fmt.Sprintf("%s/fission-function/%s", routerURL, functionName)
}
return fmt.Sprintf("%s/fission-function/%s/%s", routerURL, namespace, functionName)
}
// handleInvokeRoute даёт пользователю прямой HTTP gateway к своей функции по route.
// Внешний контракт: /fn/<route> + Authorization: Bearer <user-token>.
func (s *Server) handleInvokeRoute(w http.ResponseWriter, r *http.Request) {
@@ -836,7 +843,7 @@ func (s *Server) handleInvokeRoute(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(w, resp.Body)
}
// handleDeleteFunction удаляет функцию и связанные объекты: HTTPTrigger, Package.
// handleDeleteFunction удаляет функцию и связанные объекты: HTTPTrigger, TimeTrigger, Package.
// После удаления вызывает CleanupEnvironmentIfUnused — убирает environment если язык больше не используется.
func (s *Server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
@@ -868,6 +875,16 @@ func (s *Server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, na
}
}
// Удаляем связанные TimeTrigger-ы
if triggers, err := s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{}); err == nil {
for _, trig := range triggers.Items {
refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name")
if refName == name {
_ = s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).Delete(ctx, trig.GetName(), metav1.DeleteOptions{})
}
}
}
if err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete function %q: %v", name, err))
return
+11 -2
View File
@@ -124,6 +124,11 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
uiHandler := http.StripPrefix("/console", ui.Handler())
mux.Handle("/console", uiHandler)
mux.Handle("/console/", uiHandler)
cronHandler := ui.CronHandler()
mux.Handle("/cron", cronHandler)
mux.Handle("/cron/", cronHandler)
mux.Handle("/console/cron", cronHandler)
mux.Handle("/console/cron/", cronHandler)
// Auth: не требует токена — сам проверяет и возвращает namespace
mux.HandleFunc("/console/api/auth", s.handleAuth)
@@ -137,7 +142,10 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/functions", s.handleFunctionsRoot)
mux.HandleFunc("/api/functions/", s.handleFunctionsAction)
mux.HandleFunc("/api/httptriggers", s.handleList(fission.HTTPTrigGVR))
mux.HandleFunc("/api/timetriggers", s.handleList(fission.TimeTrigGVR))
mux.HandleFunc("/api/timetriggers", s.handleTimeTriggersRoot)
mux.HandleFunc("/api/timetriggers/", s.handleTimeTriggersAction)
mux.HandleFunc("/cron/api/metrics", s.handleCronMetrics)
mux.HandleFunc("/console/cron/api/metrics", s.handleCronMetrics)
// Основные /console/api/* маршруты
mux.HandleFunc("/console/api/environments", auth(s.handleList(fission.EnvironmentGVR)))
@@ -146,7 +154,8 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/console/api/functions/", auth(s.handleFunctionsAction))
mux.HandleFunc("/fn/", auth(s.handleInvokeRoute))
mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR)))
mux.HandleFunc("/console/api/timetriggers", auth(s.handleList(fission.TimeTrigGVR)))
mux.HandleFunc("/console/api/timetriggers", auth(s.handleTimeTriggersRoot))
mux.HandleFunc("/console/api/timetriggers/", auth(s.handleTimeTriggersAction))
mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus))
mux.HandleFunc("/console/api/ns/debug", auth(s.handleNSDebug))
mux.HandleFunc("/console/api/ai/check", auth(s.handleAICheck))
+480
View File
@@ -0,0 +1,480 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"time"
"fission-console/internal/fission"
"fission-console/internal/model"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
)
const (
timeTriggerDefaultMethod = http.MethodPost
timeTriggerDefaultSubPath = "/"
timeTriggerMaxNameLen = 63
)
var validTimeTriggerMethods = map[string]struct{}{
http.MethodGet: {},
http.MethodPost: {},
http.MethodPut: {},
http.MethodDelete: {},
http.MethodHead: {},
}
func (s *Server) handleTimeTriggersRoot(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleList(fission.TimeTrigGVR)(w, r)
case http.MethodPost:
s.handleCreateTimeTrigger(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleTimeTriggersAction(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/timetriggers/")
if path == r.URL.Path {
path = strings.TrimPrefix(r.URL.Path, "/console/api/timetriggers/")
}
path = strings.Trim(path, "/")
if path == "" {
http.NotFound(w, r)
return
}
parts := strings.Split(path, "/")
name := strings.TrimSpace(parts[0])
if name == "" || len(parts) != 1 {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
s.handleGetTimeTrigger(w, r, name)
case http.MethodPut:
s.handleUpdateTimeTrigger(w, r, name)
case http.MethodDelete:
s.handleDeleteTimeTrigger(w, r, name)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleCreateTimeTrigger(w http.ResponseWriter, r *http.Request) {
var req model.CreateTimeTriggerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
return
}
ns := s.userNS(r)
if s.nsManager != nil {
nsCtx, nsCancel := context.WithTimeout(r.Context(), 30*time.Second)
defer nsCancel()
if err := s.nsManager.EnsureUserNS(nsCtx, ns); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", err))
return
}
}
if err := s.ensureTimerWatchesNamespace(r.Context(), ns); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("sync timer namespaces: %v", err))
return
}
trigger, err := s.createTimeTrigger(r.Context(), ns, req)
if err != nil {
writeJSONError(w, errStatus(err), err.Error())
return
}
writeAnyJSON(w, http.StatusCreated, timeTriggerResponse(trigger))
}
func (s *Server) handleGetTimeTrigger(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
trigger, err := s.dyn.Resource(fission.TimeTrigGVR).Namespace(s.userNS(r)).Get(ctx, name, metav1.GetOptions{})
if err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, fmt.Sprintf("get timetrigger %q: %v", name, err))
return
}
writeAnyJSON(w, http.StatusOK, timeTriggerResponse(trigger))
}
func (s *Server) handleUpdateTimeTrigger(w http.ResponseWriter, r *http.Request, name string) {
var req model.CreateTimeTriggerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := s.ensureTimerWatchesNamespace(ctx, s.userNS(r)); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("sync timer namespaces: %v", err))
return
}
trigger, err := s.updateTimeTrigger(ctx, s.userNS(r), name, req)
if err != nil {
writeJSONError(w, errStatus(err), err.Error())
return
}
writeAnyJSON(w, http.StatusOK, map[string]any{
"updated": true,
"trigger": timeTriggerResponse(trigger),
})
}
func (s *Server) handleDeleteTimeTrigger(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := s.deleteTimeTrigger(ctx, s.userNS(r), name); err != nil {
writeJSONError(w, errStatus(err), err.Error())
return
}
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name})
}
func (s *Server) createTimeTrigger(ctx context.Context, ns string, req model.CreateTimeTriggerRequest) (*unstructured.Unstructured, error) {
normalized, err := normalizeTimeTriggerRequest(req)
if err != nil {
return nil, err
}
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, normalized.FunctionName, metav1.GetOptions{}); err != nil {
if apierrors.IsNotFound(err) {
return nil, &apiErr{status: http.StatusBadRequest, message: fmt.Sprintf("function %q not found", normalized.FunctionName)}
}
return nil, &apiErr{status: http.StatusBadGateway, message: fmt.Sprintf("get function %q: %v", normalized.FunctionName, err)}
}
trigger := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "TimeTrigger",
"metadata": map[string]any{
"name": normalized.Name,
"namespace": ns,
},
"spec": map[string]any{
"cron": normalized.Cron,
"functionref": map[string]any{
"type": "name",
"name": normalized.FunctionName,
},
"method": normalized.Method,
"subpath": normalized.SubPath,
},
}}
created, err := s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).Create(ctx, trigger, metav1.CreateOptions{})
if err != nil {
if apierrors.IsAlreadyExists(err) {
return nil, &apiErr{status: http.StatusConflict, message: fmt.Sprintf("timetrigger %q already exists", normalized.Name)}
}
if apierrors.IsInvalid(err) {
return nil, &apiErr{status: http.StatusBadRequest, message: fmt.Sprintf("invalid timetrigger spec: %v", err)}
}
return nil, &apiErr{status: http.StatusBadGateway, message: fmt.Sprintf("create timetrigger: %v", err)}
}
return created, nil
}
func (s *Server) updateTimeTrigger(ctx context.Context, ns, name string, req model.CreateTimeTriggerRequest) (*unstructured.Unstructured, error) {
normalized, err := normalizeTimeTriggerRequest(req)
if err != nil {
return nil, err
}
trigger, err := s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
return nil, &apiErr{status: http.StatusNotFound, message: fmt.Sprintf("timetrigger %q not found", name)}
}
return nil, &apiErr{status: http.StatusBadGateway, message: fmt.Sprintf("get timetrigger %q: %v", name, err)}
}
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, normalized.FunctionName, metav1.GetOptions{}); err != nil {
if apierrors.IsNotFound(err) {
return nil, &apiErr{status: http.StatusBadRequest, message: fmt.Sprintf("function %q not found", normalized.FunctionName)}
}
return nil, &apiErr{status: http.StatusBadGateway, message: fmt.Sprintf("get function %q: %v", normalized.FunctionName, err)}
}
if err := unstructured.SetNestedField(trigger.Object, normalized.Cron, "spec", "cron"); err != nil {
return nil, &apiErr{status: http.StatusInternalServerError, message: fmt.Sprintf("set timetrigger cron: %v", err)}
}
if err := unstructured.SetNestedField(trigger.Object, map[string]any{
"type": "name",
"name": normalized.FunctionName,
}, "spec", "functionref"); err != nil {
return nil, &apiErr{status: http.StatusInternalServerError, message: fmt.Sprintf("set timetrigger functionref: %v", err)}
}
if err := unstructured.SetNestedField(trigger.Object, normalized.Method, "spec", "method"); err != nil {
return nil, &apiErr{status: http.StatusInternalServerError, message: fmt.Sprintf("set timetrigger method: %v", err)}
}
if err := unstructured.SetNestedField(trigger.Object, normalized.SubPath, "spec", "subpath"); err != nil {
return nil, &apiErr{status: http.StatusInternalServerError, message: fmt.Sprintf("set timetrigger subpath: %v", err)}
}
updated, err := s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).Update(ctx, trigger, metav1.UpdateOptions{})
if err != nil {
if apierrors.IsInvalid(err) {
return nil, &apiErr{status: http.StatusBadRequest, message: fmt.Sprintf("invalid timetrigger spec: %v", err)}
}
return nil, &apiErr{status: http.StatusBadGateway, message: fmt.Sprintf("update timetrigger %q: %v", name, err)}
}
return updated, nil
}
func (s *Server) deleteTimeTrigger(ctx context.Context, ns, name string) error {
if err := s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
return &apiErr{status: http.StatusBadGateway, message: fmt.Sprintf("delete timetrigger %q: %v", name, err)}
}
return nil
}
func normalizeTimeTriggerRequest(req model.CreateTimeTriggerRequest) (model.CreateTimeTriggerRequest, error) {
req.Name = strings.TrimSpace(req.Name)
req.FunctionName = strings.TrimSpace(req.FunctionName)
req.Cron = strings.TrimSpace(req.Cron)
req.Method = strings.TrimSpace(req.Method)
req.SubPath = strings.TrimSpace(req.SubPath)
if req.Name == "" {
return req, &apiErr{status: http.StatusBadRequest, message: "name is required"}
}
if !validFuncName.MatchString(req.Name) || len(req.Name) > timeTriggerMaxNameLen {
return req, &apiErr{status: http.StatusBadRequest, message: "invalid timetrigger name: must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ and be <= 63 chars"}
}
if req.FunctionName == "" {
return req, &apiErr{status: http.StatusBadRequest, message: "functionName is required"}
}
if !validFuncName.MatchString(req.FunctionName) || len(req.FunctionName) > timeTriggerMaxNameLen {
return req, &apiErr{status: http.StatusBadRequest, message: "invalid functionName: must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ and be <= 63 chars"}
}
if req.Cron == "" {
return req, &apiErr{status: http.StatusBadRequest, message: "cron is required"}
}
req.Method = strings.ToUpper(req.Method)
if req.Method == "" {
req.Method = timeTriggerDefaultMethod
}
if _, ok := validTimeTriggerMethods[req.Method]; !ok {
return req, &apiErr{status: http.StatusBadRequest, message: "invalid method: must be GET, POST, PUT, DELETE or HEAD"}
}
if req.SubPath == "" || req.SubPath == timeTriggerDefaultSubPath {
req.SubPath = timeTriggerDefaultSubPath
} else if !strings.HasPrefix(req.SubPath, "/") {
req.SubPath = "/" + req.SubPath
}
return req, nil
}
func (s *Server) ensureTimerWatchesNamespace(ctx context.Context, userNS string) error {
userNS = strings.TrimSpace(userNS)
if userNS == "" {
return nil
}
deployGVR := schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
deploy, err := s.dyn.Resource(deployGVR).Namespace(fissionSystemNamespace()).Get(ctx, "timer", metav1.GetOptions{})
if err != nil {
return fmt.Errorf("get timer deployment: %w", err)
}
containers, found, err := unstructured.NestedSlice(deploy.Object, "spec", "template", "spec", "containers")
if err != nil {
return fmt.Errorf("read timer containers: %w", err)
}
if !found {
return fmt.Errorf("timer containers not found")
}
if len(containers) == 0 {
return fmt.Errorf("timer containers empty")
}
container, ok := containers[0].(map[string]any)
if !ok {
return fmt.Errorf("timer container has unexpected shape")
}
envList, found, err := unstructured.NestedSlice(container, "env")
if err != nil {
return fmt.Errorf("read timer env: %w", err)
}
if !found {
return fmt.Errorf("timer env not found")
}
defaultNS := "default"
resourceNamespaces := []string{"default"}
defaultIdx := -1
resourceIdx := -1
for i, item := range envList {
env, ok := item.(map[string]any)
if !ok {
continue
}
name, _ := env["name"].(string)
value, _ := env["value"].(string)
switch name {
case "FISSION_DEFAULT_NAMESPACE":
defaultIdx = i
if strings.TrimSpace(value) != "" {
defaultNS = strings.TrimSpace(value)
}
case "FISSION_RESOURCE_NAMESPACES":
resourceIdx = i
resourceNamespaces = splitCSVNamespaces(value)
}
}
if len(resourceNamespaces) == 0 {
resourceNamespaces = []string{defaultNS}
}
resourceNamespaces = appendNamespace(resourceNamespaces, userNS)
resourceNamespaces = ensureDefaultFirst(resourceNamespaces, defaultNS)
joined := strings.Join(resourceNamespaces, ",")
changed := false
if defaultIdx >= 0 {
env := envList[defaultIdx].(map[string]any)
if env["value"] != defaultNS {
env["value"] = defaultNS
envList[defaultIdx] = env
changed = true
}
}
if resourceIdx >= 0 {
env := envList[resourceIdx].(map[string]any)
if env["value"] != joined {
env["value"] = joined
envList[resourceIdx] = env
changed = true
}
}
if !changed {
return nil
}
container["env"] = envList
containers[0] = container
if err := unstructured.SetNestedSlice(deploy.Object, containers, "spec", "template", "spec", "containers"); err != nil {
return fmt.Errorf("write timer env: %w", err)
}
if _, err := s.dyn.Resource(deployGVR).Namespace(fissionSystemNamespace()).Update(ctx, deploy, metav1.UpdateOptions{}); err != nil {
return fmt.Errorf("update timer deployment: %w", err)
}
return nil
}
func splitCSVNamespaces(raw string) []string {
parts := strings.Split(raw, ",")
seen := make(map[string]struct{}, len(parts))
result := make([]string, 0, len(parts))
for _, part := range parts {
name := strings.TrimSpace(part)
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
result = append(result, name)
}
return result
}
func appendNamespace(namespaces []string, ns string) []string {
for _, existing := range namespaces {
if existing == ns {
return namespaces
}
}
return append(namespaces, ns)
}
func ensureDefaultFirst(namespaces []string, defaultNS string) []string {
uniq := splitCSVNamespaces(strings.Join(namespaces, ","))
others := make([]string, 0, len(uniq))
for _, ns := range uniq {
if ns != defaultNS {
others = append(others, ns)
}
}
sort.Strings(others)
return append([]string{defaultNS}, others...)
}
func timeTriggerResponse(trigger *unstructured.Unstructured) map[string]any {
result := map[string]any{}
if trigger == nil {
return result
}
result["raw"] = trigger.Object
result["name"] = trigger.GetName()
result["namespace"] = trigger.GetNamespace()
if cron, found, _ := unstructured.NestedString(trigger.Object, "spec", "cron"); found {
result["cron"] = cron
}
if method, found, _ := unstructured.NestedString(trigger.Object, "spec", "method"); found {
result["method"] = method
}
if subpath, found, _ := unstructured.NestedString(trigger.Object, "spec", "subpath"); found {
result["subpath"] = subpath
}
if functionName, found, _ := unstructured.NestedString(trigger.Object, "spec", "functionref", "name"); found {
result["function"] = functionName
}
return result
}
type apiErr struct {
status int
message string
}
func (e *apiErr) Error() string {
if e == nil {
return ""
}
return e.message
}
func errStatus(err error) int {
if err == nil {
return http.StatusInternalServerError
}
if ae, ok := err.(*apiErr); ok && ae.status != 0 {
return ae.status
}
return http.StatusInternalServerError
}
+140
View File
@@ -0,0 +1,140 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"fission-console/internal/fission"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
runtime "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
dynamicfake "k8s.io/client-go/dynamic/fake"
)
func TestHandleTimeTriggersRootCreateListGetUpdateDelete(t *testing.T) {
ctx := context.Background()
scheme := runtime.NewScheme()
client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(
scheme,
map[schema.GroupVersionResource]string{
fission.TimeTrigGVR: "TimeTriggerList",
},
functionObject("fission-test", "hello"),
)
s := &Server{dyn: client, ns: "fission-test"}
createReq := httptest.NewRequest(http.MethodPost, "/console/api/timetriggers", strings.NewReader(`{"name":"cron-job","functionName":"hello","cron":"*/5 * * * *","method":"get","subpath":"logs"}`))
createRec := httptest.NewRecorder()
s.handleTimeTriggersRoot(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create status = %d, want %d body=%s", createRec.Code, http.StatusCreated, createRec.Body.String())
}
created := decodeMap(t, createRec.Body.Bytes())
if created["name"] != "cron-job" {
t.Fatalf("create name = %v", created["name"])
}
if created["cron"] != "*/5 * * * *" {
t.Fatalf("create cron = %v", created["cron"])
}
if created["method"] != http.MethodGet {
t.Fatalf("create method = %v", created["method"])
}
if created["subpath"] != "/logs" {
t.Fatalf("create subpath = %v", created["subpath"])
}
createdObj, err := client.Resource(fission.TimeTrigGVR).Namespace("fission-test").Get(ctx, "cron-job", metav1.GetOptions{})
if err != nil {
t.Fatalf("get created timetrigger: %v", err)
}
if cron, _, _ := unstructured.NestedString(createdObj.Object, "spec", "cron"); cron != "*/5 * * * *" {
t.Fatalf("stored cron = %q", cron)
}
listReq := httptest.NewRequest(http.MethodGet, "/console/api/timetriggers", nil)
listRec := httptest.NewRecorder()
s.handleTimeTriggersRoot(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("list status = %d body=%s", listRec.Code, listRec.Body.String())
}
if !strings.Contains(listRec.Body.String(), "cron-job") {
t.Fatalf("list body does not contain created trigger: %s", listRec.Body.String())
}
getReq := httptest.NewRequest(http.MethodGet, "/console/api/timetriggers/cron-job", nil)
getRec := httptest.NewRecorder()
s.handleTimeTriggersAction(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("get status = %d body=%s", getRec.Code, getRec.Body.String())
}
got := decodeMap(t, getRec.Body.Bytes())
if got["function"] != "hello" {
t.Fatalf("get function = %v", got["function"])
}
updateReq := httptest.NewRequest(http.MethodPut, "/console/api/timetriggers/cron-job", strings.NewReader(`{"name":"cron-job","functionName":"hello","cron":"@hourly","method":"post","subpath":"/"}`))
updateRec := httptest.NewRecorder()
s.handleTimeTriggersAction(updateRec, updateReq)
if updateRec.Code != http.StatusOK {
t.Fatalf("update status = %d body=%s", updateRec.Code, updateRec.Body.String())
}
updated, err := client.Resource(fission.TimeTrigGVR).Namespace("fission-test").Get(ctx, "cron-job", metav1.GetOptions{})
if err != nil {
t.Fatalf("get updated timetrigger: %v", err)
}
if cron, _, _ := unstructured.NestedString(updated.Object, "spec", "cron"); cron != "@hourly" {
t.Fatalf("updated cron = %q", cron)
}
deleteReq := httptest.NewRequest(http.MethodDelete, "/console/api/timetriggers/cron-job", nil)
deleteRec := httptest.NewRecorder()
s.handleTimeTriggersAction(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK {
t.Fatalf("delete status = %d body=%s", deleteRec.Code, deleteRec.Body.String())
}
if _, err := client.Resource(fission.TimeTrigGVR).Namespace("fission-test").Get(ctx, "cron-job", metav1.GetOptions{}); !apierrors.IsNotFound(err) {
t.Fatalf("expected timetrigger to be deleted, got err=%v", err)
}
}
func TestHandleCreateTimeTriggerRejectsMissingFunction(t *testing.T) {
client := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme())
s := &Server{dyn: client, ns: "fission-test"}
req := httptest.NewRequest(http.MethodPost, "/console/api/timetriggers", strings.NewReader(`{"name":"cron-job","functionName":"missing","cron":"*/5 * * * *"}`))
rec := httptest.NewRecorder()
s.handleTimeTriggersRoot(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "not found") {
t.Fatalf("expected not found error, got %s", rec.Body.String())
}
}
func decodeMap(t *testing.T, data []byte) map[string]any {
t.Helper()
var result map[string]any
if err := json.Unmarshal(data, &result); err != nil {
t.Fatalf("decode json: %v body=%s", err, string(data))
}
return result
}
func functionObject(namespace, name string) *unstructured.Unstructured {
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Function",
"metadata": map[string]any{
"name": name,
"namespace": namespace,
},
}}
}
+9
View File
@@ -17,6 +17,15 @@ type CreateFunctionRequest struct {
TTL string `json:"ttl"` // e.g. "24h", "7d" — пустое = функция не протухает
}
// CreateTimeTriggerRequest — тело POST /console/api/timetriggers.
type CreateTimeTriggerRequest struct {
Name string `json:"name"`
FunctionName string `json:"functionName"`
Cron string `json:"cron"`
Method string `json:"method"`
SubPath string `json:"subpath"`
}
// UpdateCodeRequest — тело PUT /console/api/functions/:name/code.
type UpdateCodeRequest struct {
Code string `json:"code"`
+16
View File
@@ -0,0 +1,16 @@
package ui
import (
_ "embed"
"net/http"
)
//go:embed cron.html
var cronPage string
func CronHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(cronPage))
})
}
+379
View File
@@ -0,0 +1,379 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NUBES Cron</title>
<style>
:root {
--bg-page: #001120;
--bg-surface: #001929;
--bg-navbar: #001c34;
--border: #0b2d50;
--accent: #1a7fd4;
--text-primary: #e2ecf6;
--text-secondary: #6b8eaa;
--good: #56d364;
--warn: #e3b341;
--bad: #f85149;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
background: linear-gradient(180deg, #001120 0%, #061424 100%);
color: var(--text-primary);
min-height: 100vh;
}
a { color: inherit; text-decoration: none; }
.navbar {
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: var(--bg-navbar);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand-mark {
width: 30px; height: 30px; border-radius: 8px;
background: linear-gradient(135deg, #009dff 0%, #0055d4 100%);
color: #fff; display: grid; place-items: center;
font-size: 14px; font-weight: 800; letter-spacing: .02em;
box-shadow: 0 6px 18px rgba(0, 125, 255, .35);
}
.brand-text { display: flex; flex-direction: column; line-height: 1.08; }
.brand-text .nubes { font-size: 12px; letter-spacing: .22em; font-weight: 800; color: #8bc7ff; }
.brand-text .product { font-size: 13px; font-weight: 700; letter-spacing: .04em; }
.btn {
background: var(--accent); color: #fff; border: 0; border-radius: 6px;
padding: 8px 12px; cursor: pointer; font-size: 13px; line-height: 1.2;
}
.btn.ghost { background: #0c2b49; }
.btn:hover { opacity: .95; }
.wrap { max-width: 1320px; margin: 0 auto; padding: 20px; }
.hero {
background: linear-gradient(180deg, #001929 0%, #001622 100%);
border: 1px solid var(--border); border-radius: 14px; padding: 18px;
box-shadow: 0 12px 34px rgba(0, 0, 0, .18);
}
.hero-top { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
.hero h1 { font-size: 28px; margin-bottom: 6px; }
.hero p { color: var(--text-secondary); max-width: 920px; line-height: 1.5; font-size: 14px; }
.hero .actions { display: flex; gap: 8px; flex-wrap: wrap; }
.clock {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 14px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(12, 43, 73, .72);
border: 1px solid #1d486d;
color: #d7e7f7;
font-size: 14px;
font-weight: 700;
letter-spacing: .02em;
}
.clock small {
display: block;
font-size: 11px;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
margin-bottom: 2px;
}
.grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px;
margin-top: 16px;
}
.card {
background: var(--bg-surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px;
}
.k { color: var(--text-secondary); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
.v { font-size: 24px; font-weight: 800; margin-top: 6px; }
.s { font-size: 12px; color: var(--text-secondary); margin-top: 4px; min-height: 16px; }
.panel {
margin-top: 14px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: 12px;
overflow: hidden;
}
.panel-head {
display: flex; justify-content: space-between; gap: 12px; align-items: center; flex-wrap: wrap;
padding: 14px 16px; border-bottom: 1px solid var(--border);
}
.panel-head h2 { font-size: 16px; }
.meta { color: var(--text-secondary); font-size: 12px; }
table { width: 100%; border-collapse: collapse; min-width: 1220px; }
th, td { padding: 9px 11px; border-bottom: 1px solid #0b2a48; text-align: left; }
th {
color: var(--text-secondary); font-size: 11px; text-transform: uppercase; letter-spacing: .05em; white-space: nowrap;
}
td { font-size: 13px; vertical-align: middle; }
tbody tr:hover { background: rgba(26, 127, 212, .08); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
.status-ok { color: var(--good); }
.status-warn { color: var(--warn); }
.status-bad { color: var(--bad); }
.empty { color: var(--text-secondary); padding: 24px 12px; text-align: center; }
.footer {
padding-top: 12px; color: var(--text-secondary); font-size: 12px; line-height: 1.5;
}
.badge {
display: inline-block; padding: 2px 7px; border-radius: 999px; background: #0c2b49; border: 1px solid #1d486d;
color: #d7e7f7; font-size: 11px;
}
@media (max-width: 820px) {
.hero h1 { font-size: 22px; }
}
</style>
</head>
<body>
<div class="navbar">
<div class="brand">
<div class="brand-mark">N</div>
<div class="brand-text">
<div class="nubes">NUBES</div>
<div class="product">CRON METRICS</div>
</div>
</div>
<div class="actions">
<button class="btn ghost" id="btn-refresh">Refresh</button>
<a class="btn ghost" href="/console/">Console</a>
</div>
</div>
<div class="wrap">
<section class="hero">
<div class="hero-top">
<div>
<h1>Снимки состояния по cron</h1>
<p>
Python-функция по расписанию отправляет сюда snapshot со свободной памятью, CPU load, диском и uptime.
Страница показывает последний снимок и историю замеров в том же стиле, что и основной dashboard: карточки + таблица.
</p>
<div class="clock">
<div>
<small>Current time</small>
<span id="current-time">--:--:--</span>
</div>
</div>
</div>
<div class="actions">
<span class="badge" id="points-badge">0 points</span>
</div>
</div>
<div class="grid" id="summary"></div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>History</h2>
<div class="meta" id="last-updated">Ожидание первых данных…</div>
</div>
<div class="meta mono" id="latest-source"></div>
</div>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Time</th>
<th>Source</th>
<th>Host</th>
<th>Free mem</th>
<th>Avail mem</th>
<th>Used mem</th>
<th>Mem %</th>
<th>CPU 1m</th>
<th>CPU 5m</th>
<th>CPU 15m</th>
<th>Disk free</th>
<th>Uptime</th>
<th>Status</th>
</tr>
</thead>
<tbody id="history-body">
<tr><td class="empty" colspan="13">Данных пока нет</td></tr>
</tbody>
</table>
</div>
</section>
<div class="footer">
Endpoint для cron-функции: <span class="mono">POST /cron/api/metrics</span>.<br>
Пример полезной нагрузки: <span class="mono">{"source":"my-fn","memory_free_mb":1234,"cpu_load_1m":0.12}</span>.
</div>
</div>
<script>
const API = '/cron/api/metrics';
const summaryEl = document.getElementById('summary');
const historyBody = document.getElementById('history-body');
const lastUpdatedEl = document.getElementById('last-updated');
const latestSourceEl = document.getElementById('latest-source');
const pointsBadge = document.getElementById('points-badge');
const currentTimeEl = document.getElementById('current-time');
function num(v) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function fmtNum(v, d = 1, suffix = '') {
const n = num(v);
if (n === null) return '—';
return n.toFixed(d) + suffix;
}
function fmtTime(v) {
if (!v) return '—';
const d = new Date(v);
if (Number.isNaN(d.getTime())) return String(v);
return new Intl.DateTimeFormat('ru-RU', {
year: '2-digit', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
}).format(d);
}
function updateClock() {
currentTimeEl.textContent = new Intl.DateTimeFormat('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
day: '2-digit',
month: '2-digit',
year: 'numeric'
}).format(new Date());
}
function fmtUptime(v) {
const sec = num(v);
if (sec === null) return '—';
const days = Math.floor(sec / 86400);
const hours = Math.floor((sec % 86400) / 3600);
const mins = Math.floor((sec % 3600) / 60);
if (days > 0) return `${days}d ${hours}h ${mins}m`;
if (hours > 0) return `${hours}h ${mins}m`;
return `${Math.floor(sec)}s`;
}
function cls(status) {
const s = String(status || '').toLowerCase();
if (s.includes('err') || s.includes('fail') || s.includes('bad')) return 'status-bad';
if (s.includes('warn') || s.includes('degrad') || s.includes('idle')) return 'status-warn';
return 'status-ok';
}
function card(label, value, subtitle) {
return `
<div class="card">
<div class="k">${label}</div>
<div class="v">${value}</div>
<div class="s">${subtitle || ''}</div>
</div>
`;
}
function renderSummary(latest, count) {
if (!latest) {
summaryEl.innerHTML = [
card('Snapshots', '0', 'Waiting for cron POSTs'),
card('Free memory', '—', 'MB'),
card('Available memory', '—', 'MB'),
card('Used memory', '—', 'MB'),
card('CPU load', '—', '1m / 5m / 15m'),
card('Disk free', '—', 'GB')
].join('');
pointsBadge.textContent = '0 points';
return;
}
summaryEl.innerHTML = [
card('Snapshots', String(count), `Source: ${latest.source || 'cron'}`),
card('Free memory', fmtNum(latest.memory_free_mb, 1, ' MB'), latest.hostname || '—'),
card('Available memory', fmtNum(latest.memory_available_mb, 1, ' MB'), 'Current available RAM'),
card('Used memory', fmtNum(latest.memory_used_mb, 1, ' MB'), `Mem ${fmtNum(latest.memory_percent, 1, '%')}`),
card('CPU load', [fmtNum(latest.cpu_load_1m, 2), fmtNum(latest.cpu_load_5m, 2), fmtNum(latest.cpu_load_15m, 2)].join(' / '), '1m / 5m / 15m'),
card('Disk free', fmtNum(latest.disk_free_gb, 1, ' GB'), `Uptime ${fmtUptime(latest.uptime_sec)}`)
].join('');
pointsBadge.textContent = `${count} point(s)`;
}
function renderTable(history) {
if (!history.length) {
historyBody.innerHTML = '<tr><td class="empty" colspan="13">Данных пока нет</td></tr>';
return;
}
historyBody.innerHTML = history.slice().reverse().map(function (item) {
return `
<tr>
<td class="mono">${fmtTime(item.timestamp)}</td>
<td>${item.source || '—'}</td>
<td>${item.hostname || '—'}</td>
<td class="mono">${fmtNum(item.memory_free_mb, 1)}</td>
<td class="mono">${fmtNum(item.memory_available_mb, 1)}</td>
<td class="mono">${fmtNum(item.memory_used_mb, 1)}</td>
<td class="mono">${fmtNum(item.memory_percent, 1, '%')}</td>
<td class="mono">${fmtNum(item.cpu_load_1m, 2)}</td>
<td class="mono">${fmtNum(item.cpu_load_5m, 2)}</td>
<td class="mono">${fmtNum(item.cpu_load_15m, 2)}</td>
<td class="mono">${fmtNum(item.disk_free_gb, 1, ' GB')}</td>
<td class="mono">${fmtUptime(item.uptime_sec)}</td>
<td class="${cls(item.status)}">${item.status || 'ok'}</td>
</tr>
`;
}).join('');
}
function updateMeta(history) {
if (!history.length) {
lastUpdatedEl.textContent = 'Ожидание первых данных…';
latestSourceEl.textContent = '';
return;
}
const latest = history[history.length - 1];
lastUpdatedEl.textContent = `Последний снимок: ${fmtTime(latest.timestamp)}${latest.hostname ? ' · ' + latest.hostname : ''}`;
latestSourceEl.textContent = latest.source ? `source=${latest.source}` : '';
}
async function loadMetrics() {
try {
const res = await fetch(API, { headers: { 'Accept': 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const history = Array.isArray(data.history) ? data.history : [];
const latest = data.latest || (history.length ? history[history.length - 1] : null);
renderSummary(latest, history.length || (latest ? 1 : 0));
renderTable(history);
updateMeta(history);
} catch (err) {
summaryEl.innerHTML = [
card('Snapshots', '—', 'Load error'),
card('Free memory', '—', err.message),
card('Available memory', '—', 'Check backend'),
card('Used memory', '—', 'Check cron function'),
card('CPU load', '—', 'Check backend'),
card('Disk free', '—', 'Check ingest')
].join('');
historyBody.innerHTML = `<tr><td class="empty" colspan="13">Ошибка загрузки: ${err.message}</td></tr>`;
lastUpdatedEl.textContent = 'Ошибка обновления';
latestSourceEl.textContent = '';
pointsBadge.textContent = '0 points';
}
}
document.getElementById('btn-refresh').addEventListener('click', loadMetrics);
updateClock();
setInterval(updateClock, 1000);
loadMetrics();
setInterval(loadMetrics, 30000);
</script>
</body>
</html>
+123 -10
View File
@@ -416,6 +416,7 @@
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
<button class="btn" onclick="openCreate()">+ Создать функцию</button>
<button class="btn ghost" onclick="openHelp()">Help</button>
<a class="btn ghost" href="/cron/" target="_blank" rel="noopener">Cron</a>
<button class="btn ghost" onclick="doLogout()" style="margin-left:8px;">Выход</button>
</div>
</div>
@@ -438,6 +439,10 @@
<div class="k">HTTP-триггеры</div>
<div id="http-count" class="v">-</div>
</div>
<div class="card">
<div class="k">Крон-функции</div>
<div id="cron-count" class="v">-</div>
</div>
</div>
<div class="box">
@@ -455,6 +460,7 @@
<th>Изменена</th>
<th>Маршрут</th>
<th>Методы</th>
<th>Cron</th>
<th class="nowrap">Действия</th>
</tr>
</thead>
@@ -501,6 +507,18 @@
<input id="c-timeout" type="number" min="1" value="60">
</div>
</div>
<div class="row">
<div class="field" style="min-width:240px; flex:0 0 260px;">
<label style="display:flex; align-items:center; gap:8px; color:var(--text-primary); margin-top:18px;">
<input id="c-schedule-enabled" type="checkbox" onchange="toggleScheduleFields('c')" style="width:auto;">
Выполнять по расписанию
</label>
</div>
<div class="field">
<label>Cron</label>
<input id="c-cron" placeholder="*/5 * * * *" disabled>
</div>
</div>
<div>
<label>Код</label>
<textarea id="c-code">def main():
@@ -559,6 +577,18 @@
<input id="e-timeout" type="number" min="1" value="60">
</div>
</div>
<div class="row">
<div class="field" style="min-width:240px; flex:0 0 260px;">
<label style="display:flex; align-items:center; gap:8px; color:var(--text-primary); margin-top:18px;">
<input id="e-schedule-enabled" type="checkbox" onchange="toggleScheduleFields('e')" style="width:auto;">
Выполнять по расписанию
</label>
</div>
<div class="field">
<label>Cron</label>
<input id="e-cron" placeholder="*/5 * * * *" disabled>
</div>
</div>
<div>
<label>Код</label>
<textarea id="e-code"></textarea>
@@ -695,7 +725,8 @@
const S = {
envs: [],
fns: [],
triggers: [],
httpTriggers: [],
timeTriggers: [],
currentEdit: null,
currentInvoke: null
};
@@ -795,12 +826,65 @@
return warning + '\n' + body;
}
function triggerByFn(fnName) {
return (S.triggers || []).find(function (t) {
function httpTriggerByFn(fnName) {
return (S.httpTriggers || []).find(function (t) {
return t.spec && t.spec.functionref && t.spec.functionref.name === fnName;
});
}
function timeTriggerByFn(fnName) {
return (S.timeTriggers || []).find(function (t) {
return t.spec && t.spec.functionref && t.spec.functionref.name === fnName;
});
}
function toggleScheduleFields(prefix) {
var enabled = !!document.getElementById(prefix + '-schedule-enabled').checked;
var cronEl = document.getElementById(prefix + '-cron');
cronEl.disabled = !enabled;
if (enabled && !cronEl.value.trim()) {
cronEl.value = '*/5 * * * *';
}
}
function schedulePayload(prefix) {
return {
enabled: !!document.getElementById(prefix + '-schedule-enabled').checked,
cron: (document.getElementById(prefix + '-cron').value || '').trim()
};
}
async function syncScheduleForFunction(name, prefix) {
var schedule = schedulePayload(prefix);
var existing = timeTriggerByFn(name);
var existingName = (existing && existing.metadata && existing.metadata.name) || name;
if (!schedule.enabled) {
if (existing) {
await requestJSON(API_BASE + '/timetriggers/' + encodeURIComponent(existingName), 'DELETE');
}
return;
}
if (!schedule.cron) {
throw new Error('cron is required');
}
var payload = {
name: existingName,
functionName: name,
cron: schedule.cron,
method: 'POST',
subpath: '/'
};
if (existing) {
await requestJSON(API_BASE + '/timetriggers/' + encodeURIComponent(existingName), 'PUT', payload);
} else {
await requestJSON(API_BASE + '/timetriggers', 'POST', payload);
}
}
function h(v) {
return String(v == null ? '' : v)
.replaceAll('&', '&amp;')
@@ -861,6 +945,9 @@
document.getElementById('c-lang').value = 'python';
onLangChange();
document.getElementById('c-timeout').value = '60';
document.getElementById('c-schedule-enabled').checked = false;
document.getElementById('c-cron').value = '';
toggleScheduleFields('c');
document.getElementById('create-modal').classList.add('open');
document.getElementById('c-name').focus();
}
@@ -889,6 +976,17 @@
code: document.getElementById('c-code').value
});
try {
await syncScheduleForFunction(name, 'c');
} catch (scheduleErr) {
try {
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE');
} catch (rollbackErr) {
scheduleErr.message += '; rollback failed: ' + rollbackErr.message;
}
throw scheduleErr;
}
closeCreate();
progress.stop('\u0424\u0443\u043d\u043a\u0446\u0438\u044f \u0441\u043e\u0437\u0434\u0430\u043d\u0430: ' + name, 'ok');
await reloadAll();
@@ -909,6 +1007,10 @@
document.getElementById('e-entry').value = fn.entrypoint || '';
document.getElementById('e-timeout').value = String(fn.timeout || 60);
document.getElementById('e-code').value = fn.code || '';
var schedule = timeTriggerByFn(name);
document.getElementById('e-schedule-enabled').checked = !!schedule;
document.getElementById('e-cron').value = (schedule && schedule.spec && schedule.spec.cron) || '';
toggleScheduleFields('e');
// Определяем язык по имени environment для линтера
var envName = (fn.environment || '').toLowerCase();
var lang = 'python';
@@ -955,6 +1057,8 @@
code: document.getElementById('e-code').value,
timeout: parseTimeout(document.getElementById('e-timeout').value)
});
await syncScheduleForFunction(name, 'e');
closeEdit();
progress.stop('\u041a\u043e\u0434 \u043e\u0431\u043d\u043e\u0432\u043b\u0451\u043d: ' + name, 'ok');
await reloadAll();
@@ -1047,21 +1151,26 @@
async function reloadAll() {
try {
const [envs, pkgs, fns, http] = await Promise.all([
const [envs, pkgs, fns, http, times] = await Promise.all([
getJSON(API_BASE + '/environments'),
getJSON(API_BASE + '/packages'),
getJSON(API_BASE + '/functions'),
getJSON(API_BASE + '/httptriggers')
getJSON(API_BASE + '/httptriggers'),
getJSON(API_BASE + '/timetriggers')
]);
S.envs = envs || [];
S.fns = fns || [];
S.triggers = http || [];
S.httpTriggers = http || [];
S.timeTriggers = times || [];
setText('env-count', envs.length || 0);
setText('pkg-count', pkgs.length || 0);
setText('fn-count', fns.length || 0);
setText('http-count', http.length || 0);
setText('cron-count', new Set((times || []).map(function (t) {
return t && t.spec && t.spec.functionref && t.spec.functionref.name;
}).filter(Boolean)).size);
const rows = (fns || []).map(function (f) {
const spec = f.spec || {};
@@ -1070,10 +1179,13 @@
const env = (spec.environment && spec.environment.name) || '-';
const pkg = (spec.package && spec.package.packageref && spec.package.packageref.name) || '-';
const name = (f.metadata && f.metadata.name) || '-';
const trig = triggerByFn(name) || {};
const trig = httpTriggerByFn(name) || {};
const timeTrig = timeTriggerByFn(name) || {};
const route = (trig.spec && trig.spec.relativeurl) || '-';
const methods = (trig.spec && trig.spec.methods) || [];
const chips = methods.map(function (m) { return '<span class="chip">' + h(m) + '</span>'; }).join('');
const cron = (timeTrig.spec && timeTrig.spec.cron) || '';
const cronCell = cron ? '<span class="chip">' + h(cron) + '</span>' : '<span class="mono" style="color:var(--text-secondary)">—</span>';
const createdAt = ann['fission-console/created-at'] || meta.creationTimestamp || '-';
const updatedAt = ann['fission-console/updated-at'] || createdAt;
var isGo = /go[-_]env/.test(env);
@@ -1091,16 +1203,17 @@
'<td>' + timestampCell(updatedAt) + '</td>' +
'<td class="mono">' + h(route) + '</td>' +
'<td>' + chips + '</td>' +
'<td>' + cronCell + '</td>' +
'<td class="nowrap">' + actions + '</td>' +
'</tr>';
}).join('');
document.getElementById('fn-rows').innerHTML = rows || '<tr><td colspan="8">\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439</td></tr>';
document.getElementById('fn-rows').innerHTML = rows || '<tr><td colspan="9">\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439</td></tr>';
if (!rows) {
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="8">\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439</td></tr>';
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="9">\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439</td></tr>';
}
} catch (e) {
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="8">Load error: ' + e.message + '</td></tr>';
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="9">Load error: ' + e.message + '</td></tr>';
showStatus('\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438: ' + e.message, 'err');
}
}
@@ -0,0 +1,25 @@
# 2026-04-29 — Cron router auth finding
## Что нашли
Проблема в cron-цепочке сейчас не в самом `TimeTrigger` и не в console metrics storage. Разрыв происходит на входе в `router`: `timer` публикует invoke-запрос без `Authorization: Bearer <JWT>`, а router с включенным auth отвечает `401 unauthorized: malformed token`.
## Почему это важно
Это объясняет оба симптома:
- cron tick реально происходит;
- функция не доходит до выполнения и не отправляет метрику в console;
- `/cron/api/metrics` остается пустым.
## Что подтверждает вывод
- В `doc/thinking/2026-04-26-layer1-pass.md` зафиксирован наш layer1-патч для `fission-router` RBAC и namespace watch.
- В `doc/thinking/2026-04-15.md` зафиксирован рабочий auth flow console → `POST /auth/login` → JWT → invoke через router.
- В live logs timer видно повторяющиеся ответы router:
- `status_code=401`
- `body=unauthorized: malformed token`
## Итог
Cron-механизм Fission в целом живой, но для timer-вызова сейчас не хватает router JWT auth. Пока timer не будет ходить в router с корректным Bearer-токеном, функция `croned` не выполнится и метрика в console не появится.