diff --git a/CRON/CLI.md b/CRON/CLI.md new file mode 100644 index 0000000..e15bf8e --- /dev/null +++ b/CRON/CLI.md @@ -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`. +- При отображении расписания используется время сервера, а не локальное время клиента. diff --git a/CRON/CRD.md b/CRON/CRD.md new file mode 100644 index 0000000..6144a55 --- /dev/null +++ b/CRON/CRD.md @@ -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`. diff --git a/CRON/EXAMPLES.md b/CRON/EXAMPLES.md new file mode 100644 index 0000000..22df671 --- /dev/null +++ b/CRON/EXAMPLES.md @@ -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`. diff --git a/CRON/FISSION_CRON_NOTES.md b/CRON/FISSION_CRON_NOTES.md new file mode 100644 index 0000000..d7ce435 --- /dev/null +++ b/CRON/FISSION_CRON_NOTES.md @@ -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. diff --git a/CRON/GLOSSARY.md b/CRON/GLOSSARY.md new file mode 100644 index 0000000..26d7492 --- /dev/null +++ b/CRON/GLOSSARY.md @@ -0,0 +1,8 @@ +# CRON glossary + +- `TimeTrigger` — CRD-объект Fission для запуска функции по расписанию. +- `timetrigger` — CLI-команда для управления time triggers. +- `cron` — строка расписания, по которой Fission планирует вызов функции. +- `functionref` — ссылка на функцию, которую должен вызывать триггер. +- `method` — HTTP-метод вызова функции. +- `subpath` — путь внутри функции, если она поддерживает внутренний routing. diff --git a/CRON/GPT54_HANDOFF.md b/CRON/GPT54_HANDOFF.md new file mode 100644 index 0000000..b33359a --- /dev/null +++ b/CRON/GPT54_HANDOFF.md @@ -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, +потом короткая проверка живым сценарием. diff --git a/CRON/PYTHON_CRON_FUNCTION.md b/CRON/PYTHON_CRON_FUNCTION.md new file mode 100644 index 0000000..77cd2b6 --- /dev/null +++ b/CRON/PYTHON_CRON_FUNCTION.md @@ -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. +- Функция не требует внешних библиотек. diff --git a/CRON/README.md b/CRON/README.md new file mode 100644 index 0000000..de7a1dc --- /dev/null +++ b/CRON/README.md @@ -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) — короткие определения терминов diff --git a/console/deploy/console.yaml b/console/deploy/console.yaml index 9eb25e5..70559f5 100644 --- a/console/deploy/console.yaml +++ b/console/deploy/console.yaml @@ -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 diff --git a/console/internal/api/cron_metrics.go b/console/internal/api/cron_metrics.go new file mode 100644 index 0000000..5869b9b --- /dev/null +++ b/console/internal/api/cron_metrics.go @@ -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 + } +} diff --git a/console/internal/api/handlers.go b/console/internal/api/handlers.go index dfdf557..be97816 100644 --- a/console/internal/api/handlers.go +++ b/console/internal/api/handlers.go @@ -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/ + Authorization: Bearer . 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 diff --git a/console/internal/api/server.go b/console/internal/api/server.go index ea125bf..b3f844f 100644 --- a/console/internal/api/server.go +++ b/console/internal/api/server.go @@ -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)) diff --git a/console/internal/api/timetriggers.go b/console/internal/api/timetriggers.go new file mode 100644 index 0000000..aad1987 --- /dev/null +++ b/console/internal/api/timetriggers.go @@ -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 +} \ No newline at end of file diff --git a/console/internal/api/timetriggers_test.go b/console/internal/api/timetriggers_test.go new file mode 100644 index 0000000..030ac92 --- /dev/null +++ b/console/internal/api/timetriggers_test.go @@ -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, + }, + }} +} diff --git a/console/internal/model/types.go b/console/internal/model/types.go index d74240d..8ddb88a 100644 --- a/console/internal/model/types.go +++ b/console/internal/model/types.go @@ -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"` diff --git a/console/ui/cron.go b/console/ui/cron.go new file mode 100644 index 0000000..3764e24 --- /dev/null +++ b/console/ui/cron.go @@ -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)) + }) +} diff --git a/console/ui/cron.html b/console/ui/cron.html new file mode 100644 index 0000000..e8f6618 --- /dev/null +++ b/console/ui/cron.html @@ -0,0 +1,379 @@ + + + + + + NUBES Cron + + + + + +
+
+
+
+

Снимки состояния по cron

+

+ Python-функция по расписанию отправляет сюда snapshot со свободной памятью, CPU load, диском и uptime. + Страница показывает последний снимок и историю замеров в том же стиле, что и основной dashboard: карточки + таблица. +

+
+
+ Current time + --:--:-- +
+
+
+
+ 0 points +
+
+ +
+
+ +
+
+
+

History

+
Ожидание первых данных…
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + +
TimeSourceHostFree memAvail memUsed memMem %CPU 1mCPU 5mCPU 15mDisk freeUptimeStatus
Данных пока нет
+
+
+ + +
+ + + + diff --git a/console/ui/index.html b/console/ui/index.html index d00531b..6333ecf 100644 --- a/console/ui/index.html +++ b/console/ui/index.html @@ -416,6 +416,7 @@ + Cron @@ -438,6 +439,10 @@
HTTP-триггеры
-
+
+
Крон-функции
+
-
+
@@ -455,6 +460,7 @@ Изменена Маршрут Методы + Cron Действия @@ -501,6 +507,18 @@
+
+
+ +
+
+ + +
+
@@ -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('&', '&') @@ -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 '' + h(m) + ''; }).join(''); + const cron = (timeTrig.spec && timeTrig.spec.cron) || ''; + const cronCell = cron ? '' + h(cron) + '' : ''; 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 @@ '' + timestampCell(updatedAt) + '' + '' + h(route) + '' + '' + chips + '' + + '' + cronCell + '' + '' + actions + '' + ''; }).join(''); - document.getElementById('fn-rows').innerHTML = rows || '\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439'; + document.getElementById('fn-rows').innerHTML = rows || '\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439'; if (!rows) { - document.getElementById('fn-rows').innerHTML = '\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439'; + document.getElementById('fn-rows').innerHTML = '\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439'; } } catch (e) { - document.getElementById('fn-rows').innerHTML = 'Load error: ' + e.message + ''; + document.getElementById('fn-rows').innerHTML = 'Load error: ' + e.message + ''; showStatus('\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438: ' + e.message, 'err'); } } diff --git a/doc/thinking/2026-04-29-cron-router-auth-findings.md b/doc/thinking/2026-04-29-cron-router-auth-findings.md new file mode 100644 index 0000000..ef7c53e --- /dev/null +++ b/doc/thinking/2026-04-29-cron-router-auth-findings.md @@ -0,0 +1,25 @@ +# 2026-04-29 — Cron router auth finding + +## Что нашли + +Проблема в cron-цепочке сейчас не в самом `TimeTrigger` и не в console metrics storage. Разрыв происходит на входе в `router`: `timer` публикует invoke-запрос без `Authorization: Bearer `, а 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 не появится. \ No newline at end of file