From 8c3385693f5ba9a79ce873ccf8896db815026f69 Mon Sep 17 00:00:00 2001 From: Naeel Date: Wed, 15 Apr 2026 06:44:24 +0300 Subject: [PATCH 01/28] doc: add Fission Console UI plan for Codex --- doc/CODEX_UI_PLAN.md | 805 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 805 insertions(+) create mode 100644 doc/CODEX_UI_PLAN.md diff --git a/doc/CODEX_UI_PLAN.md b/doc/CODEX_UI_PLAN.md new file mode 100644 index 0000000..2b14dc9 --- /dev/null +++ b/doc/CODEX_UI_PLAN.md @@ -0,0 +1,805 @@ +# Fission Console — план разработки UI + +Дата: 2026-04-15 +Автор: Claude Opus → план для GPT 5.3 Codex +Статус: ПЛАН (к реализации) + +--- + +## Цель + +Создать веб-консоль для Fission — аналог `sless-funcs-service` из проекта sless. +Консоль позволяет: смотреть функции, создавать, редактировать код, вызывать, управлять триггерами. + +## Референсные файлы (ОБЯЗАТЕЛЬНО ИЗУЧИТЬ) + +Перед написанием кода — прочитай эти файлы из sless (монтирован рядом на VM): + +| Файл | Зачем | +|------|-------| +| `~/terra/sless/services/funcs/main.go` (942 строки) | **Главный референс** — Go backend funcs-service. Роутинг, proxy к оператору, рендеринг HTML с JSON data injection | +| `~/terra/sless/services/funcs/index.html` (1146 строк) | **Главный референс** — UI. Vanilla JS SPA, карточки функций, создание, редактирование, highlight.js, build polling | +| `~/terra/sless/services/funcs/Dockerfile` | Multi-stage Go+embed сборка | +| `~/terra/sless/services/funcs/funcs-service.yaml` | K8s Deployment+Service+Ingress | +| `~/terra/sless/internal/api/ui/iot-console.html` (1330 строк) | Референс для IoT-стиля UI (Nubes branding, login, табы, модалки) | +| `~/terra/sless/shared-sqs/app/ui/embed.go` | Паттерн go:embed | +| `~/terra/sless/shared-sqs/app/ui/index.html` (932 строки) | Ещё один референс SPA (queue CRUD) | +| `~/terra/sless/doc/decisions/funcs_console_editor.md` | Архитектурные решения по редактору кода | + +## Архитектура + +### Компоненты + +``` +User Browser + └─► https://fission.kube5s.ru/console + └─► nginx Ingress (уже есть для fission router) + └─► fission-console:8090 (новый pod в namespace fission) + ├─► GET /console → HTML SPA (go:embed) + ├─► GET /api/environments → kubectl proxy → CRD list + ├─► GET /api/functions → kubectl proxy → CRD list + ├─► GET /api/packages → kubectl proxy → CRD list + ├─► GET /api/triggers → kubectl proxy → CRD list + ├─► POST /api/functions → create CRD + ├─► POST /api/functions/{name}/code → update package literal + ├─► POST /api/functions/{name}/invoke → proxy → router + ├─► DELETE /api/functions/{name} → delete CRD + └─► GET /api/functions/{name}/status → pod status +``` + +### Технологии + +- **Backend:** Go, net/http, k8s client-go (dynamic client) +- **Frontend:** Vanilla HTML/CSS/JS (один файл, go:embed) +- **Подсветка кода:** highlight.js (CDN) +- **Стиль:** Nubes dark theme (идентичный sless/IoT) +- **Деплой:** Docker multi-stage → K8s Deployment + Service + Ingress + +### Почему dynamic client (не REST API): + +Fission в нашем кластере НЕ имеет REST `/v2` API. +Все данные — через Kubernetes CRD (environments.fission.io, functions.fission.io итд). +Текущий Terraform provider уже работает через dynamic client — переиспользуем тот же подход. + +--- + +## Структура файлов (создать) + +``` +console/ +├── main.go # HTTP server + API handlers + k8s client +├── ui/ +│ ├── embed.go # go:embed index.html +│ └── index.html # SPA: весь CSS + JS +├── go.mod +├── Dockerfile # multi-stage: Go → alpine +└── deploy/ + └── console.yaml # K8s Deployment + Service + Ingress rule +``` + +--- + +## Этап 1: Backend (main.go) — ~400-500 строк Go + +### 1.1 Инициализация + +```go +// Переменные окружения: +// KUBECONFIG — путь к kubeconfig (dev) или in-cluster +// FISSION_NAMESPACE — namespace с функциями (default: "default") +// FISSION_ROUTER_URL — внутренний URL роутера (default: "http://router.fission.svc.cluster.local") +// PORT — порт (default: "8090") +``` + +- Создать dynamic client (как в terraform provider — client.go) +- Маршруты: `/console` (UI), `/api/*` (JSON API), `/health` + +### 1.2 API endpoints + +| Метод | Путь | Что делает | +|-------|------|-----------| +| GET | /api/environments | List environments.fission.io → JSON array | +| GET | /api/packages | List packages.fission.io → JSON array | +| GET | /api/functions | List functions.fission.io → JSON array | +| GET | /api/httptriggers | List httptriggers.fission.io → JSON array | +| GET | /api/timetriggers | List timetriggers.fission.io → JSON array | +| GET | /api/functions/{name} | Get function + связанный package + trigger info | +| POST | /api/functions | Create: environment → package → function → httptrigger (всё за один вызов) | +| PUT | /api/functions/{name}/code | Update package spec.deployment.literal (новый код) | +| POST | /api/functions/{name}/invoke | Proxy → fission router: POST http://router/fission-function/v2/functions/{name} | +| DELETE | /api/functions/{name} | Delete function + httptrigger + package (каскадно) | +| GET | /api/pods | List pods по label svc=... в namespace "default" (показать runtime pods) | + +### 1.3 Формат ответа (JSON) + +Для листинга функций — объединённая структура: + +```json +{ + "functions": [ + { + "name": "hello", + "environment": "python", + "package": "hello-pkg", + "entrypoint": "main.main", + "code": "def main():\n return hello\n", + "trigger_url": "/hello", + "trigger_methods": ["GET"], + "invoke_url": "https://fission.kube5s.ru/hello", + "created": "2026-04-14T18:30:00Z" + } + ], + "environments": [ + {"name": "python", "image": "ghcr.io/fission/python-env", "version": 3} + ] +} +``` + +### 1.4 Create function — полный flow + +POST /api/functions с телом: +```json +{ + "name": "my-func", + "environment": "python", + "code": "def main():\n return {hello: world}\n", + "entrypoint": "main.main", + "route": "/my-func", + "methods": ["GET", "POST"] +} +``` + +Backend создаёт 3 CRD объекта: +1. Package (`{name}-pkg`) — spec.environment + spec.deployment.literal (base64 код) +2. Function (`{name}`) — spec.environment + spec.package + spec.invokeStrategy +3. HTTPTrigger (`{name}-route`) — spec.functionref + spec.relativeurl + spec.methods + +Если environment не существует — вернуть ошибку (не создавать). + +### 1.5 Update code + +PUT /api/functions/{name}/code с телом: `{"code": "..."}` + +1. Найти function CRD → получить package name +2. Обновить package spec.deployment.literal (base64 encode) +3. Вернуть 200 + +### 1.6 Invoke + +POST /api/functions/{name}/invoke с телом: произвольный JSON + +Proxy → `http://router.fission.svc.cluster.local/fission-function/v2/functions/{name}` +Вернуть: status code + body + latency + +### 1.7 Каскадное удаление + +DELETE /api/functions/{name}: +1. Получить function → имя package и trigger +2. Удалить httptrigger(s) связанные с функцией +3. Удалить function +4. Удалить package + +--- + +## Этап 2: Frontend (index.html) — ~800-1000 строк + +### 2.1 Дизайн — Nubes dark theme + +CSS переменные (скопировать из sless): +```css +:root { + --bg-page: #001120; + --bg-surface: #001929; + --bg-navbar: #001C34; + --border: #0b2d50; + --accent: #1a7fd4; + --text-primary: #e2ecf6; + --text-secondary: #6b8eaa; + --danger: #e74c3c; + --success: #27ae60; +} +``` + +### 2.2 Страницы/секции + +#### Navbar +``` +[Nubes logo] [/] [Fission Console] [namespace: default] [↻ Refresh] [+ Создать функцию] +``` + +#### Секция: Dashboard (summary) +``` +Environments: 3 | Functions: 8 | Triggers: 7 | Router: fission.kube5s.ru +``` + +#### Секция: Карточки функций (как в sless funcs-service) + +Каждая функция — карточка: +``` +┌────────────────────────────────────────────────────────────┐ +│ hello-fn [python] [Ready] https://fission.kube5s.ru/hello │ +│ ✎ Edit ▶ Invoke ✕ Delete │ +├────────────────────────────────────────────────────────────┤ +│ (expand) → показать код с подсветкой (highlight.js) │ +│ (edit mode) → textarea + Save │ +│ (invoke mode) → JSON input + Send + Response display │ +└────────────────────────────────────────────────────────────┘ +``` + +#### Модалка: Создать функцию +``` +┌─── Создать функцию ──────────────────────────┐ +│ Environment: [python ▾] [nodejs ▾] [go ▾] │ +│ Имя: [my-func_________] │ +│ Entrypoint: [main.main_______] │ +│ Route: [/my-func________] │ +│ Файл: main.py │ +│ ┌──────────────────────────────────────┐ │ +│ │ def main(): │ │ +│ │ return {"hello": "world"} │ │ +│ └──────────────────────────────────────┘ │ +│ [Отмена] [Создать] │ +└───────────────────────────────────────────────┘ +``` + +При смене environment → обновить шаблон кода/файла: +- python → main.py, `def main(): ...` +- nodejs → main.js, `module.exports = async function(req) { ... }` +- go → main.go, `func Handler(w http.ResponseWriter, r *http.Request) { ... }` + +#### Inline: Invoke функции + +``` +┌─── Invoke: hello-fn ─────────────────────────┐ +│ Input JSON: │ +│ ┌──────────────────────────────────┐ │ +│ │ {"name": "world"} │ │ +│ └──────────────────────────────────┘ │ +│ [▶ Send] │ +│ │ +│ Response (200, 34ms): │ +│ ┌──────────────────────────────────┐ │ +│ │ {"hello": "world"} │ │ +│ └──────────────────────────────────┘ │ +└────────────────────────────────────────────────┘ +``` + +### 2.3 JavaScript — SPA логика + +Принципы (из sless referenceов): +- Весь state в объекте `S = { functions: [], environments: [], ... }` +- `render()` — главный диспетчер +- Partial DOM updates по ID (не полный re-render) +- `fetch(/api/...)` для всех операций +- `h()` — HTML escape utility +- Карточки: expand/collapse для кода, inline edit, inline invoke + +--- + +## Этап 3: Сборка и деплой + +### 3.1 go.mod + +``` +module fission-console + +go 1.24.0 + +require ( + k8s.io/apimachinery v0.32.3 + k8s.io/client-go v0.32.3 +) +``` + +Важно: использовать те же версии k8s что в terraform/provider/go.mod. + +### 3.2 Dockerfile + +```dockerfile +FROM golang:1.24-alpine AS builder +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o fission-console . + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates +COPY --from=builder /build/fission-console /fission-console +EXPOSE 8090 +ENTRYPOINT ["/fission-console"] +``` + +### 3.3 K8s manifest (deploy/console.yaml) + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fission-console + namespace: fission +spec: + replicas: 1 + selector: + matchLabels: + app: fission-console + template: + metadata: + labels: + app: fission-console + spec: + serviceAccountName: fission-console + containers: + - name: console + image: naeel/fission-console:v0.1.0 + ports: + - containerPort: 8090 + env: + - name: FISSION_NAMESPACE + value: "default" + - name: FISSION_ROUTER_URL + value: "http://router.fission.svc.cluster.local" + - name: PORT + value: "8090" + livenessProbe: + httpGet: {path: /health, port: 8090} + initialDelaySeconds: 5 + resources: + requests: {cpu: 10m, memory: 16Mi} + limits: {cpu: 100m, memory: 64Mi} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: fission-console + namespace: fission +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: fission-console +rules: + - apiGroups: ["fission.io"] + resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"] + verbs: ["get", "list", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: fission-console +subjects: + - kind: ServiceAccount + name: fission-console + namespace: fission +roleRef: + kind: ClusterRole + name: fission-console + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: v1 +kind: Service +metadata: + name: fission-console + namespace: fission +spec: + selector: + app: fission-console + ports: + - port: 8090 + targetPort: 8090 +--- +# Ingress: добавить path /console к существующему fission ingress +# ИЛИ создать отдельный Ingress для fission-console +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: fission-console + namespace: fission + annotations: + nginx.ingress.kubernetes.io/force-ssl-redirect: "true" +spec: + ingressClassName: nginx + rules: + - host: fission.kube5s.ru + http: + paths: + - path: /console + pathType: Prefix + backend: + service: + name: fission-console + port: + number: 8090 + tls: + - hosts: [fission.kube5s.ru] + secretName: fission-tls # проверить имя секрета +``` + +--- + +## Этап 4: Тестирование + +### 4.1 Smoke test (вручную в браузере) +1. Открыть https://fission.kube5s.ru/console +2. Увидеть список существующих функций +3. Создать новую функцию (Python) → увидеть в списке +4. Отредактировать код → сохранить +5. Invoke → получить ответ +6. Удалить → исчезла из списка + +### 4.2 curl smoke +```bash +# API листинг +curl -s https://fission.kube5s.ru/console/api/functions | jq . + +# Создать функцию +curl -s -X POST https://fission.kube5s.ru/console/api/functions \ + -H Content-Type: application/json \ + -d name:test-ui + +# Invoke +curl -s -X POST https://fission.kube5s.ru/console/api/functions/test-ui/invoke + +# Удалить +curl -s -X DELETE https://fission.kube5s.ru/console/api/functions/test-ui +``` + +--- + +## Порядок выполнения (для Codex) + +| # | Шаг | Файл(ы) | Критерий готовности | +|---|-----|---------|-------------------| +| 1 | Создать структуру console/ + go.mod | console/ | `go mod tidy` проходит | +| 2 | Написать main.go — HTTP server + k8s client init + health endpoint | main.go | Компилируется, /health отвечает | +| 3 | Написать API handlers: list environments/functions/packages/triggers | main.go | `curl /api/functions` возвращает JSON | +| 4 | Написать API handler: create function (env→pkg→fn→trigger) | main.go | POST создаёт 3 CRD | +| 5 | Написать API handler: update code (package literal) | main.go | PUT обновляет пакет | +| 6 | Написать API handler: invoke (proxy → router) | main.go | POST вызывает функцию | +| 7 | Написать API handler: delete (каскадно) | main.go | DELETE удаляет fn+pkg+trigger | +| 8 | Создать ui/embed.go | ui/embed.go | — | +| 9 | Создать index.html — CSS (Nubes dark theme) + navbar + layout | ui/index.html | Открывается с правильным стилем | +| 10 | JS: загрузка данных + рендер карточек функций | ui/index.html | Карточки отображаются | +| 11 | JS: expand карточки → показать код с подсветкой | ui/index.html | Код виден | +| 12 | JS: модалка создания функции + шаблоны Python/JS/Go | ui/index.html | Функция создаётся через UI | +| 13 | JS: inline edit mode + save | ui/index.html | Код обновляется | +| 14 | JS: inline invoke панель | ui/index.html | Вызов и ответ показываются | +| 15 | JS: delete с подтверждением | ui/index.html | Удаление работает | +| 16 | Dockerfile + добавить в .gitignore | Dockerfile, .gitignore | `docker build` проходит | +| 17 | K8s manifest + RBAC | deploy/console.yaml | Pod запускается, /health OK | +| 18 | Ingress → smoke test в браузере | deploy/console.yaml | UI доступен по HTTPS | +| 19 | Commit + push | — | Всё в git | + +--- + +## КРИТИЧЕСКИЕ ПРАВИЛА ВЫПОЛНЕНИЯ + +1. **ВСЁ через SSH на VM** — `ssh naeel@5.172.178.213`. Файлы писать локально (sshfs mount), команды — по SSH. +2. **Образы на Docker Hub** (`naeel/fission-console`). НЕ использовать pearlharbor. +3. **Nubes branding** — идентичный sless. CSS variables из референса. +4. **Vanilla JS** — без React/Vue/Angular. Один HTML файл. +5. **go:embed** — index.html встраивается в бинарник. +6. **Рабочая директория:** `~/terra/fission/console/` +7. **Проверять go.mod версии** — взять k8s-* из `~/terra/fission/terraform/provider/go.mod` +8. **Не ломать существующий provider** — console/ отдельный Go module +9. **Перед docker build** — убедиться что бинарник в .gitignore +10. **Commit + push** после каждого рабочего этапа + +--- + +## Что НЕ делать + +- Auth/login — пока не нужен (демо-режим, без токенов) +- Multi-namespace — работаем с одним namespace (default) +- Управление environments через UI — только листинг (создание через terraform) +- Логи функций (stdout/stderr) — отложить +- Version history — отложить +- TimeTriggers CRUD — только отображение (создание — terraform) +ENDOFPLAN application/json \ + -d environment:python + +# Invoke +curl -s -X POST https://fission.kube5s.ru/console/api/functions/test-ui/invoke + +# Удалить +curl -s -X DELETE https://fission.kube5s.ru/console/api/functions/test-ui +``` + +--- + +## Порядок выполнения (для Codex) + +| # | Шаг | Файл(ы) | Критерий готовности | +|---|-----|---------|-------------------| +| 1 | Создать структуру console/ + go.mod | console/ | `go mod tidy` проходит | +| 2 | Написать main.go — HTTP server + k8s client init + health endpoint | main.go | Компилируется, /health отвечает | +| 3 | Написать API handlers: list environments/functions/packages/triggers | main.go | `curl /api/functions` возвращает JSON | +| 4 | Написать API handler: create function (env→pkg→fn→trigger) | main.go | POST создаёт 3 CRD | +| 5 | Написать API handler: update code (package literal) | main.go | PUT обновляет пакет | +| 6 | Написать API handler: invoke (proxy → router) | main.go | POST вызывает функцию | +| 7 | Написать API handler: delete (каскадно) | main.go | DELETE удаляет fn+pkg+trigger | +| 8 | Создать ui/embed.go | ui/embed.go | — | +| 9 | Создать index.html — CSS (Nubes dark theme) + navbar + layout | ui/index.html | Открывается с правильным стилем | +| 10 | JS: загрузка данных + рендер карточек функций | ui/index.html | Карточки отображаются | +| 11 | JS: expand карточки → показать код с подсветкой | ui/index.html | Код виден | +| 12 | JS: модалка создания функции + шаблоны Python/JS/Go | ui/index.html | Функция создаётся через UI | +| 13 | JS: inline edit mode + save | ui/index.html | Код обновляется | +| 14 | JS: inline invoke панель | ui/index.html | Вызов и ответ показываются | +| 15 | JS: delete с подтверждением | ui/index.html | Удаление работает | +| 16 | Dockerfile + добавить в .gitignore | Dockerfile, .gitignore | `docker build` проходит | +| 17 | K8s manifest + RBAC | deploy/console.yaml | Pod запускается, /health OK | +| 18 | Ingress → smoke test в браузере | deploy/console.yaml | UI доступен по HTTPS | +| 19 | Commit + push | — | Всё в git | + +--- + +## КРИТИЧЕСКИЕ ПРАВИЛА ВЫПОЛНЕНИЯ + +1. **ВСЁ через SSH на VM** — `ssh naeel@5.172.178.213`. Файлы писать локально (sshfs mount), команды — по SSH. +2. **Образы на Docker Hub** (`naeel/fission-console`). НЕ использовать pearlharbor. +3. **Nubes branding** — идентичный sless. CSS variables из референса. +4. **Vanilla JS** — без React/Vue/Angular. Один HTML файл. +5. **go:embed** — index.html встраивается в бинарник. +6. **Рабочая директория:** `~/terra/fission/console/` +7. **Проверять go.mod версии** — взять k8s-* из `~/terra/fission/terraform/provider/go.mod` +8. **Не ломать существующий provider** — console/ отдельный Go module +9. **Перед docker build** — убедиться что бинарник в .gitignore +10. **Commit + push** после каждого рабочего этапа + +--- + +## Что НЕ делать + +- Auth/login — пока не нужен (демо-режим, без токенов) +- Multi-namespace — работаем с одним namespace (default) +- Управление environments через UI — только листинг (создание через terraform) +- Логи функций (stdout/stderr) — отложить +- Version history — отложить +- TimeTriggers CRUD — только отображение (создание — terraform) +ENDOFPLAN application/json \ + -d code:def main():\n return {"ok":true} + +# Invoke +curl -s -X POST https://fission.kube5s.ru/console/api/functions/test-ui/invoke + +# Удалить +curl -s -X DELETE https://fission.kube5s.ru/console/api/functions/test-ui +``` + +--- + +## Порядок выполнения (для Codex) + +| # | Шаг | Файл(ы) | Критерий готовности | +|---|-----|---------|-------------------| +| 1 | Создать структуру console/ + go.mod | console/ | `go mod tidy` проходит | +| 2 | Написать main.go — HTTP server + k8s client init + health endpoint | main.go | Компилируется, /health отвечает | +| 3 | Написать API handlers: list environments/functions/packages/triggers | main.go | `curl /api/functions` возвращает JSON | +| 4 | Написать API handler: create function (env→pkg→fn→trigger) | main.go | POST создаёт 3 CRD | +| 5 | Написать API handler: update code (package literal) | main.go | PUT обновляет пакет | +| 6 | Написать API handler: invoke (proxy → router) | main.go | POST вызывает функцию | +| 7 | Написать API handler: delete (каскадно) | main.go | DELETE удаляет fn+pkg+trigger | +| 8 | Создать ui/embed.go | ui/embed.go | — | +| 9 | Создать index.html — CSS (Nubes dark theme) + navbar + layout | ui/index.html | Открывается с правильным стилем | +| 10 | JS: загрузка данных + рендер карточек функций | ui/index.html | Карточки отображаются | +| 11 | JS: expand карточки → показать код с подсветкой | ui/index.html | Код виден | +| 12 | JS: модалка создания функции + шаблоны Python/JS/Go | ui/index.html | Функция создаётся через UI | +| 13 | JS: inline edit mode + save | ui/index.html | Код обновляется | +| 14 | JS: inline invoke панель | ui/index.html | Вызов и ответ показываются | +| 15 | JS: delete с подтверждением | ui/index.html | Удаление работает | +| 16 | Dockerfile + добавить в .gitignore | Dockerfile, .gitignore | `docker build` проходит | +| 17 | K8s manifest + RBAC | deploy/console.yaml | Pod запускается, /health OK | +| 18 | Ingress → smoke test в браузере | deploy/console.yaml | UI доступен по HTTPS | +| 19 | Commit + push | — | Всё в git | + +--- + +## КРИТИЧЕСКИЕ ПРАВИЛА ВЫПОЛНЕНИЯ + +1. **ВСЁ через SSH на VM** — `ssh naeel@5.172.178.213`. Файлы писать локально (sshfs mount), команды — по SSH. +2. **Образы на Docker Hub** (`naeel/fission-console`). НЕ использовать pearlharbor. +3. **Nubes branding** — идентичный sless. CSS variables из референса. +4. **Vanilla JS** — без React/Vue/Angular. Один HTML файл. +5. **go:embed** — index.html встраивается в бинарник. +6. **Рабочая директория:** `~/terra/fission/console/` +7. **Проверять go.mod версии** — взять k8s-* из `~/terra/fission/terraform/provider/go.mod` +8. **Не ломать существующий provider** — console/ отдельный Go module +9. **Перед docker build** — убедиться что бинарник в .gitignore +10. **Commit + push** после каждого рабочего этапа + +--- + +## Что НЕ делать + +- Auth/login — пока не нужен (демо-режим, без токенов) +- Multi-namespace — работаем с одним namespace (default) +- Управление environments через UI — только листинг (создание через terraform) +- Логи функций (stdout/stderr) — отложить +- Version history — отложить +- TimeTriggers CRUD — только отображение (создание — terraform) +ENDOFPLAN application/json \ + -d entrypoint:main.main + +# Invoke +curl -s -X POST https://fission.kube5s.ru/console/api/functions/test-ui/invoke + +# Удалить +curl -s -X DELETE https://fission.kube5s.ru/console/api/functions/test-ui +``` + +--- + +## Порядок выполнения (для Codex) + +| # | Шаг | Файл(ы) | Критерий готовности | +|---|-----|---------|-------------------| +| 1 | Создать структуру console/ + go.mod | console/ | `go mod tidy` проходит | +| 2 | Написать main.go — HTTP server + k8s client init + health endpoint | main.go | Компилируется, /health отвечает | +| 3 | Написать API handlers: list environments/functions/packages/triggers | main.go | `curl /api/functions` возвращает JSON | +| 4 | Написать API handler: create function (env→pkg→fn→trigger) | main.go | POST создаёт 3 CRD | +| 5 | Написать API handler: update code (package literal) | main.go | PUT обновляет пакет | +| 6 | Написать API handler: invoke (proxy → router) | main.go | POST вызывает функцию | +| 7 | Написать API handler: delete (каскадно) | main.go | DELETE удаляет fn+pkg+trigger | +| 8 | Создать ui/embed.go | ui/embed.go | — | +| 9 | Создать index.html — CSS (Nubes dark theme) + navbar + layout | ui/index.html | Открывается с правильным стилем | +| 10 | JS: загрузка данных + рендер карточек функций | ui/index.html | Карточки отображаются | +| 11 | JS: expand карточки → показать код с подсветкой | ui/index.html | Код виден | +| 12 | JS: модалка создания функции + шаблоны Python/JS/Go | ui/index.html | Функция создаётся через UI | +| 13 | JS: inline edit mode + save | ui/index.html | Код обновляется | +| 14 | JS: inline invoke панель | ui/index.html | Вызов и ответ показываются | +| 15 | JS: delete с подтверждением | ui/index.html | Удаление работает | +| 16 | Dockerfile + добавить в .gitignore | Dockerfile, .gitignore | `docker build` проходит | +| 17 | K8s manifest + RBAC | deploy/console.yaml | Pod запускается, /health OK | +| 18 | Ingress → smoke test в браузере | deploy/console.yaml | UI доступен по HTTPS | +| 19 | Commit + push | — | Всё в git | + +--- + +## КРИТИЧЕСКИЕ ПРАВИЛА ВЫПОЛНЕНИЯ + +1. **ВСЁ через SSH на VM** — `ssh naeel@5.172.178.213`. Файлы писать локально (sshfs mount), команды — по SSH. +2. **Образы на Docker Hub** (`naeel/fission-console`). НЕ использовать pearlharbor. +3. **Nubes branding** — идентичный sless. CSS variables из референса. +4. **Vanilla JS** — без React/Vue/Angular. Один HTML файл. +5. **go:embed** — index.html встраивается в бинарник. +6. **Рабочая директория:** `~/terra/fission/console/` +7. **Проверять go.mod версии** — взять k8s-* из `~/terra/fission/terraform/provider/go.mod` +8. **Не ломать существующий provider** — console/ отдельный Go module +9. **Перед docker build** — убедиться что бинарник в .gitignore +10. **Commit + push** после каждого рабочего этапа + +--- + +## Что НЕ делать + +- Auth/login — пока не нужен (демо-режим, без токенов) +- Multi-namespace — работаем с одним namespace (default) +- Управление environments через UI — только листинг (создание через terraform) +- Логи функций (stdout/stderr) — отложить +- Version history — отложить +- TimeTriggers CRUD — только отображение (создание — terraform) +ENDOFPLAN application/json \ + -d route:/test-ui + +# Invoke +curl -s -X POST https://fission.kube5s.ru/console/api/functions/test-ui/invoke + +# Удалить +curl -s -X DELETE https://fission.kube5s.ru/console/api/functions/test-ui +``` + +--- + +## Порядок выполнения (для Codex) + +| # | Шаг | Файл(ы) | Критерий готовности | +|---|-----|---------|-------------------| +| 1 | Создать структуру console/ + go.mod | console/ | `go mod tidy` проходит | +| 2 | Написать main.go — HTTP server + k8s client init + health endpoint | main.go | Компилируется, /health отвечает | +| 3 | Написать API handlers: list environments/functions/packages/triggers | main.go | `curl /api/functions` возвращает JSON | +| 4 | Написать API handler: create function (env→pkg→fn→trigger) | main.go | POST создаёт 3 CRD | +| 5 | Написать API handler: update code (package literal) | main.go | PUT обновляет пакет | +| 6 | Написать API handler: invoke (proxy → router) | main.go | POST вызывает функцию | +| 7 | Написать API handler: delete (каскадно) | main.go | DELETE удаляет fn+pkg+trigger | +| 8 | Создать ui/embed.go | ui/embed.go | — | +| 9 | Создать index.html — CSS (Nubes dark theme) + navbar + layout | ui/index.html | Открывается с правильным стилем | +| 10 | JS: загрузка данных + рендер карточек функций | ui/index.html | Карточки отображаются | +| 11 | JS: expand карточки → показать код с подсветкой | ui/index.html | Код виден | +| 12 | JS: модалка создания функции + шаблоны Python/JS/Go | ui/index.html | Функция создаётся через UI | +| 13 | JS: inline edit mode + save | ui/index.html | Код обновляется | +| 14 | JS: inline invoke панель | ui/index.html | Вызов и ответ показываются | +| 15 | JS: delete с подтверждением | ui/index.html | Удаление работает | +| 16 | Dockerfile + добавить в .gitignore | Dockerfile, .gitignore | `docker build` проходит | +| 17 | K8s manifest + RBAC | deploy/console.yaml | Pod запускается, /health OK | +| 18 | Ingress → smoke test в браузере | deploy/console.yaml | UI доступен по HTTPS | +| 19 | Commit + push | — | Всё в git | + +--- + +## КРИТИЧЕСКИЕ ПРАВИЛА ВЫПОЛНЕНИЯ + +1. **ВСЁ через SSH на VM** — `ssh naeel@5.172.178.213`. Файлы писать локально (sshfs mount), команды — по SSH. +2. **Образы на Docker Hub** (`naeel/fission-console`). НЕ использовать pearlharbor. +3. **Nubes branding** — идентичный sless. CSS variables из референса. +4. **Vanilla JS** — без React/Vue/Angular. Один HTML файл. +5. **go:embed** — index.html встраивается в бинарник. +6. **Рабочая директория:** `~/terra/fission/console/` +7. **Проверять go.mod версии** — взять k8s-* из `~/terra/fission/terraform/provider/go.mod` +8. **Не ломать существующий provider** — console/ отдельный Go module +9. **Перед docker build** — убедиться что бинарник в .gitignore +10. **Commit + push** после каждого рабочего этапа + +--- + +## Что НЕ делать + +- Auth/login — пока не нужен (демо-режим, без токенов) +- Multi-namespace — работаем с одним namespace (default) +- Управление environments через UI — только листинг (создание через terraform) +- Логи функций (stdout/stderr) — отложить +- Version history — отложить +- TimeTriggers CRUD — только отображение (создание — terraform) +ENDOFPLAN application/json \ + -d methods:[GET] + +# Invoke +curl -s -X POST https://fission.kube5s.ru/console/api/functions/test-ui/invoke + +# Удалить +curl -s -X DELETE https://fission.kube5s.ru/console/api/functions/test-ui +``` + +--- + +## Порядок выполнения (для Codex) + +| # | Шаг | Файл(ы) | Критерий готовности | +|---|-----|---------|-------------------| +| 1 | Создать структуру console/ + go.mod | console/ | `go mod tidy` проходит | +| 2 | Написать main.go — HTTP server + k8s client init + health endpoint | main.go | Компилируется, /health отвечает | +| 3 | Написать API handlers: list environments/functions/packages/triggers | main.go | `curl /api/functions` возвращает JSON | +| 4 | Написать API handler: create function (env→pkg→fn→trigger) | main.go | POST создаёт 3 CRD | +| 5 | Написать API handler: update code (package literal) | main.go | PUT обновляет пакет | +| 6 | Написать API handler: invoke (proxy → router) | main.go | POST вызывает функцию | +| 7 | Написать API handler: delete (каскадно) | main.go | DELETE удаляет fn+pkg+trigger | +| 8 | Создать ui/embed.go | ui/embed.go | — | +| 9 | Создать index.html — CSS (Nubes dark theme) + navbar + layout | ui/index.html | Открывается с правильным стилем | +| 10 | JS: загрузка данных + рендер карточек функций | ui/index.html | Карточки отображаются | +| 11 | JS: expand карточки → показать код с подсветкой | ui/index.html | Код виден | +| 12 | JS: модалка создания функции + шаблоны Python/JS/Go | ui/index.html | Функция создаётся через UI | +| 13 | JS: inline edit mode + save | ui/index.html | Код обновляется | +| 14 | JS: inline invoke панель | ui/index.html | Вызов и ответ показываются | +| 15 | JS: delete с подтверждением | ui/index.html | Удаление работает | +| 16 | Dockerfile + добавить в .gitignore | Dockerfile, .gitignore | `docker build` проходит | +| 17 | K8s manifest + RBAC | deploy/console.yaml | Pod запускается, /health OK | +| 18 | Ingress → smoke test в браузере | deploy/console.yaml | UI доступен по HTTPS | +| 19 | Commit + push | — | Всё в git | + +--- + +## КРИТИЧЕСКИЕ ПРАВИЛА ВЫПОЛНЕНИЯ + +1. **ВСЁ через SSH на VM** — `ssh naeel@5.172.178.213`. Файлы писать локально (sshfs mount), команды — по SSH. +2. **Образы на Docker Hub** (`naeel/fission-console`). НЕ использовать pearlharbor. +3. **Nubes branding** — идентичный sless. CSS variables из референса. +4. **Vanilla JS** — без React/Vue/Angular. Один HTML файл. +5. **go:embed** — index.html встраивается в бинарник. +6. **Рабочая директория:** `~/terra/fission/console/` +7. **Проверять go.mod версии** — взять k8s-* из `~/terra/fission/terraform/provider/go.mod` +8. **Не ломать существующий provider** — console/ отдельный Go module +9. **Перед docker build** — убедиться что бинарник в .gitignore +10. **Commit + push** после каждого рабочего этапа + +--- + +## Что НЕ делать + +- Auth/login — пока не нужен (демо-режим, без токенов) +- Multi-namespace — работаем с одним namespace (default) +- Управление environments через UI — только листинг (создание через terraform) +- Логи функций (stdout/stderr) — отложить +- Version history — отложить +- TimeTriggers CRUD — только отображение (создание — terraform) From 7e2278371ae0cf550cf4d6066074658cc8c96862 Mon Sep 17 00:00:00 2001 From: Naeel Date: Wed, 15 Apr 2026 07:13:48 +0300 Subject: [PATCH 02/28] feat: scaffold fission console with list and function CRUD/invoke APIs --- console/go.mod | 36 +++ console/main.go | 508 ++++++++++++++++++++++++++++++++++++++++++ console/ui/embed.go | 13 ++ console/ui/index.html | 186 ++++++++++++++++ 4 files changed, 743 insertions(+) create mode 100644 console/go.mod create mode 100644 console/main.go create mode 100644 console/ui/embed.go create mode 100644 console/ui/index.html diff --git a/console/go.mod b/console/go.mod new file mode 100644 index 0000000..62c12dd --- /dev/null +++ b/console/go.mod @@ -0,0 +1,36 @@ +module fission-console + +go 1.25.0 + +require ( + k8s.io/apimachinery v0.34.1 + k8s.io/client-go v0.34.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.9.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/console/main.go b/console/main.go new file mode 100644 index 0000000..48afef6 --- /dev/null +++ b/console/main.go @@ -0,0 +1,508 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" + + "fission-console/ui" + 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" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +var ( + environmentGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "environments"} + packageGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "packages"} + functionGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "functions"} + httpTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "httptriggers"} + timeTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "timetriggers"} +) + +type server struct { + dyn dynamic.Interface + ns string + routerURL string + http *http.Client +} + +type createFunctionRequest struct { + Name string `json:"name"` + Environment string `json:"environment"` + Code string `json:"code"` + Entrypoint string `json:"entrypoint"` + Route string `json:"route"` + Methods []string `json:"methods"` +} + +type updateCodeRequest struct { + Code string `json:"code"` +} + +func main() { + kubeconfig := strings.TrimSpace(os.Getenv("KUBECONFIG")) + namespace := envDefault("FISSION_NAMESPACE", "default") + routerURL := strings.TrimRight(envDefault("FISSION_ROUTER_URL", "http://router.fission.svc.cluster.local"), "/") + port := envDefault("PORT", "8090") + + cfg, err := buildConfig(kubeconfig) + if err != nil { + log.Fatalf("build kube config: %v", err) + } + + dyn, err := dynamic.NewForConfig(cfg) + if err != nil { + log.Fatalf("create dynamic client: %v", err) + } + + s := &server{ + dyn: dyn, + ns: namespace, + routerURL: routerURL, + http: &http.Client{Timeout: 30 * time.Second}, + } + + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte("ok\n")) + }) + + uiHandler := ui.Handler() + mux.Handle("/console", uiHandler) + mux.Handle("/console/", uiHandler) + + mux.HandleFunc("/api/environments", s.handleList(environmentGVR)) + mux.HandleFunc("/api/packages", s.handleList(packageGVR)) + mux.HandleFunc("/api/functions", s.handleFunctionsRoot) + mux.HandleFunc("/api/functions/", s.handleFunctionsAction) + mux.HandleFunc("/api/httptriggers", s.handleList(httpTrigGVR)) + mux.HandleFunc("/api/timetriggers", s.handleList(timeTrigGVR)) + + httpServer := &http.Server{ + Addr: ":" + port, + Handler: withCORS(logRequests(mux)), + ReadHeaderTimeout: 10 * time.Second, + } + + log.Printf("fission-console listening on :%s (namespace=%s)", port, namespace) + log.Fatal(httpServer.ListenAndServe()) +} + +func (s *server) handleFunctionsRoot(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.handleList(functionGVR)(w, r) + case http.MethodPost: + s.handleCreateFunction(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *server) handleFunctionsAction(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/functions/") + path = strings.Trim(path, "/") + if path == "" { + http.NotFound(w, r) + return + } + + parts := strings.Split(path, "/") + name := strings.TrimSpace(parts[0]) + if name == "" { + writeJSONError(w, http.StatusBadRequest, "function name is required") + return + } + + if len(parts) == 1 { + switch r.Method { + case http.MethodGet: + s.handleGetFunction(w, r, name) + case http.MethodDelete: + s.handleDeleteFunction(w, r, name) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + return + } + + if len(parts) == 2 && parts[1] == "code" && r.Method == http.MethodPut { + s.handleUpdateFunctionCode(w, r, name) + return + } + + if len(parts) == 2 && parts[1] == "invoke" && r.Method == http.MethodPost { + s.handleInvokeFunction(w, r, name) + return + } + + http.NotFound(w, r) +} + +func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { + var req createFunctionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err)) + return + } + + req.Name = strings.TrimSpace(req.Name) + req.Environment = strings.TrimSpace(req.Environment) + req.Code = strings.TrimSpace(req.Code) + req.Entrypoint = strings.TrimSpace(req.Entrypoint) + req.Route = strings.TrimSpace(req.Route) + + if req.Name == "" || req.Environment == "" || req.Code == "" { + writeJSONError(w, http.StatusBadRequest, "name, environment and code are required") + return + } + if req.Entrypoint == "" { + req.Entrypoint = "main.main" + } + if req.Route == "" { + req.Route = "/" + req.Name + } + if len(req.Methods) == 0 { + req.Methods = []string{"GET"} + } + + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + + if _, err := s.dyn.Resource(environmentGVR).Namespace(s.ns).Get(ctx, req.Environment, metav1.GetOptions{}); err != nil { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("environment %q not found: %v", req.Environment, err)) + return + } + + pkgName := req.Name + "-pkg" + triggerName := req.Name + "-route" + + literal := base64.StdEncoding.EncodeToString([]byte(req.Code)) + pkg := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "Package", + "metadata": map[string]any{ + "name": pkgName, + "namespace": s.ns, + }, + "spec": map[string]any{ + "deployment": map[string]any{ + "type": "literal", + "literal": literal, + }, + "environment": map[string]any{ + "name": req.Environment, + "namespace": s.ns, + }, + "source": map[string]any{}, + }, + }} + + if _, err := s.dyn.Resource(packageGVR).Namespace(s.ns).Create(ctx, pkg, metav1.CreateOptions{}); err != nil { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create package: %v", err)) + return + } + + fn := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "Function", + "metadata": map[string]any{ + "name": req.Name, + "namespace": s.ns, + }, + "spec": map[string]any{ + "environment": map[string]any{ + "name": req.Environment, + "namespace": s.ns, + }, + "InvokeStrategy": map[string]any{ + "ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"}, + "StrategyType": "execution", + }, + "package": map[string]any{ + "packageref": map[string]any{"name": pkgName, "namespace": s.ns}, + "functionName": req.Entrypoint, + }, + }, + }} + + if _, err := s.dyn.Resource(functionGVR).Namespace(s.ns).Create(ctx, fn, metav1.CreateOptions{}); err != nil { + _ = s.dyn.Resource(packageGVR).Namespace(s.ns).Delete(ctx, pkgName, metav1.DeleteOptions{}) + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create function: %v", err)) + return + } + + httpTrigger := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "HTTPTrigger", + "metadata": map[string]any{ + "name": triggerName, + "namespace": s.ns, + }, + "spec": map[string]any{ + "relativeurl": req.Route, + "methods": req.Methods, + "createingress": true, + "functionref": map[string]any{ + "type": "name", + "name": req.Name, + }, + }, + }} + + if _, err := s.dyn.Resource(httpTrigGVR).Namespace(s.ns).Create(ctx, httpTrigger, metav1.CreateOptions{}); err != nil { + _ = s.dyn.Resource(functionGVR).Namespace(s.ns).Delete(ctx, req.Name, metav1.DeleteOptions{}) + _ = s.dyn.Resource(packageGVR).Namespace(s.ns).Delete(ctx, pkgName, metav1.DeleteOptions{}) + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create httptrigger: %v", err)) + return + } + + writeAnyJSON(w, http.StatusCreated, map[string]any{ + "name": req.Name, + "package": pkgName, + "httptrigger": triggerName, + "route": req.Route, + }) +} + +func (s *server) handleGetFunction(w http.ResponseWriter, r *http.Request, name string) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + fn, err := s.dyn.Resource(functionGVR).Namespace(s.ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + status := http.StatusBadGateway + if apierrors.IsNotFound(err) { + status = http.StatusNotFound + } + writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err)) + return + } + + writeAnyJSON(w, http.StatusOK, fn.Object) +} + +func (s *server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request, name string) { + var req updateCodeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err)) + return + } + req.Code = strings.TrimSpace(req.Code) + if req.Code == "" { + writeJSONError(w, http.StatusBadRequest, "code is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + + fn, err := s.dyn.Resource(functionGVR).Namespace(s.ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + status := http.StatusBadGateway + if apierrors.IsNotFound(err) { + status = http.StatusNotFound + } + writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err)) + return + } + + pkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name") + if pkgName == "" { + writeJSONError(w, http.StatusBadGateway, "function has no package reference") + return + } + + pkg, err := s.dyn.Resource(packageGVR).Namespace(s.ns).Get(ctx, pkgName, metav1.GetOptions{}) + if err != nil { + status := http.StatusBadGateway + if apierrors.IsNotFound(err) { + status = http.StatusNotFound + } + writeJSONError(w, status, fmt.Sprintf("get package %q: %v", pkgName, err)) + return + } + + literal := base64.StdEncoding.EncodeToString([]byte(req.Code)) + if err := unstructured.SetNestedField(pkg.Object, literal, "spec", "deployment", "literal"); err != nil { + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set package literal: %v", err)) + return + } + + if _, err := s.dyn.Resource(packageGVR).Namespace(s.ns).Update(ctx, pkg, metav1.UpdateOptions{}); err != nil { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update package %q: %v", pkgName, err)) + return + } + + writeAnyJSON(w, http.StatusOK, map[string]any{"updated": true, "package": pkgName}) +} + +func (s *server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, name string) { + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read request body: %v", err)) + return + } + if len(bytes.TrimSpace(bodyBytes)) == 0 { + bodyBytes = []byte("{}") + } + + ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second) + defer cancel() + + invokeURL := fmt.Sprintf("%s/fission-function/v2/functions/%s", s.routerURL, name) + start := time.Now() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, invokeURL, bytes.NewReader(bodyBytes)) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build invoke request: %v", err)) + return + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.http.Do(req) + if err != nil { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q: %v", name, err)) + return + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + writeAnyJSON(w, http.StatusOK, map[string]any{ + "status": resp.StatusCode, + "latency_ms": time.Since(start).Milliseconds(), + "invoke_url": invokeURL, + "response_raw": string(respBody), + }) +} + +func (s *server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, name string) { + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + + var pkgName string + fn, err := s.dyn.Resource(functionGVR).Namespace(s.ns).Get(ctx, name, metav1.GetOptions{}) + if err == nil { + pkgName, _, _ = unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name") + } + + triggers, err := s.dyn.Resource(httpTrigGVR).Namespace(s.ns).List(ctx, metav1.ListOptions{}) + if err == nil { + for _, trig := range triggers.Items { + refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name") + if refName == name { + _ = s.dyn.Resource(httpTrigGVR).Namespace(s.ns).Delete(ctx, trig.GetName(), metav1.DeleteOptions{}) + } + } + } + + if err := s.dyn.Resource(functionGVR).Namespace(s.ns).Delete(ctx, name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete function %q: %v", name, err)) + return + } + + if pkgName != "" { + if err := s.dyn.Resource(packageGVR).Namespace(s.ns).Delete(ctx, pkgName, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete package %q: %v", pkgName, err)) + return + } + } + + writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name, "package": pkgName}) +} + +func buildConfig(kubeconfig string) (*rest.Config, error) { + if kubeconfig != "" { + cfg, err := clientcmd.BuildConfigFromFlags("", kubeconfig) + if err == nil { + return cfg, nil + } + return nil, fmt.Errorf("kubeconfig %s: %w", kubeconfig, err) + } + + cfg, err := rest.InClusterConfig() + if err == nil { + return cfg, nil + } + + loadingRules := &clientcmd.ClientConfigLoadingRules{} + clientCfg := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{}) + return clientCfg.ClientConfig() +} + +func (s *server) handleList(gvr schema.GroupVersionResource) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + list, err := s.dyn.Resource(gvr).Namespace(s.ns).List(ctx, metav1.ListOptions{}) + if err != nil { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("list %s: %v", gvr.Resource, err)) + return + } + + writeJSON(w, http.StatusOK, list.Items) + } +} + +func writeJSON(w http.ResponseWriter, status int, data []unstructured.Unstructured) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} + +func writeAnyJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} + +func writeJSONError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{"error": msg}) +} + +func logRequests(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("%s %s", r.Method, r.URL.Path) + next.ServeHTTP(w, r) + }) +} + +func withCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func envDefault(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} diff --git a/console/ui/embed.go b/console/ui/embed.go new file mode 100644 index 0000000..20ef7f6 --- /dev/null +++ b/console/ui/embed.go @@ -0,0 +1,13 @@ +package ui + +import ( + "embed" + "net/http" +) + +//go:embed index.html +var content embed.FS + +func Handler() http.Handler { + return http.FileServer(http.FS(content)) +} diff --git a/console/ui/index.html b/console/ui/index.html new file mode 100644 index 0000000..41c95d9 --- /dev/null +++ b/console/ui/index.html @@ -0,0 +1,186 @@ + + + + + + Fission Console + + + + + +
+
+
Environments
-
+
Packages
-
+
Functions
-
+
HTTP Triggers
-
+
Time Triggers
-
+
+ +
+
Functions (MVP list)
+ + + + + +
NameEnvironmentPackage
+
Initial MVP. Next step: create/edit/invoke/delete.
+
+
+ + + + From 6731e8998bc134eb5f9fecbdc7da20094133b382 Mon Sep 17 00:00:00 2001 From: Naeel Date: Wed, 15 Apr 2026 07:33:00 +0300 Subject: [PATCH 03/28] feat: deliver fission console UI, k8s deploy, and tests --- console/Dockerfile | 12 + console/deploy/console.yaml | 114 +++++++++ console/go.mod | 11 + console/go.sum | 156 ++++++++++++ console/main.go | 147 +++++++++++- console/main_test.go | 143 +++++++++++ console/ui/index.html | 457 +++++++++++++++++++++++++++++++++++- 7 files changed, 1016 insertions(+), 24 deletions(-) create mode 100644 console/Dockerfile create mode 100644 console/deploy/console.yaml create mode 100644 console/go.sum create mode 100644 console/main_test.go diff --git a/console/Dockerfile b/console/Dockerfile new file mode 100644 index 0000000..a8491c9 --- /dev/null +++ b/console/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.26-alpine AS builder +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o fission-console . + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates +COPY --from=builder /build/fission-console /fission-console +EXPOSE 8090 +ENTRYPOINT ["/fission-console"] diff --git a/console/deploy/console.yaml b/console/deploy/console.yaml new file mode 100644 index 0000000..92fa11e --- /dev/null +++ b/console/deploy/console.yaml @@ -0,0 +1,114 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: fission-console + namespace: fission +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: fission-console +rules: + - apiGroups: ["fission.io"] + resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"] + verbs: ["get", "list", "create", "update", "patch", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: fission-console +subjects: + - kind: ServiceAccount + name: fission-console + namespace: fission +roleRef: + kind: ClusterRole + name: fission-console + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fission-console + namespace: fission + labels: + app: fission-console +spec: + replicas: 1 + selector: + matchLabels: + app: fission-console + template: + metadata: + labels: + app: fission-console + spec: + serviceAccountName: fission-console + containers: + - name: console + image: naeel/fission-console:v0.1.5 + ports: + - containerPort: 8090 + env: + - name: FISSION_NAMESPACE + value: "default" + - name: FISSION_ROUTER_URL + value: "http://router.fission.svc.cluster.local" + - name: PORT + value: "8090" + livenessProbe: + httpGet: + path: /health + port: 8090 + initialDelaySeconds: 5 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /health + port: 8090 + initialDelaySeconds: 3 + periodSeconds: 10 + resources: + requests: + cpu: 20m + memory: 32Mi + limits: + cpu: 200m + memory: 128Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: fission-console + namespace: fission +spec: + selector: + app: fission-console + ports: + - port: 8090 + targetPort: 8090 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: fission-console + namespace: fission + annotations: + nginx.ingress.kubernetes.io/force-ssl-redirect: "true" +spec: + ingressClassName: nginx + rules: + - host: fission.kube5s.ru + http: + paths: + - path: /console + pathType: Prefix + backend: + service: + name: fission-console + port: + number: 8090 + tls: + - hosts: + - fission.kube5s.ru + secretName: fission-tls diff --git a/console/go.mod b/console/go.mod index 62c12dd..e531983 100644 --- a/console/go.mod +++ b/console/go.mod @@ -11,21 +11,32 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.9.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect diff --git a/console/go.sum b/console/go.sum new file mode 100644 index 0000000..778ac55 --- /dev/null +++ b/console/go.sum @@ -0,0 +1,156 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= +k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= +k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= +k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= +k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/console/main.go b/console/main.go index 48afef6..ee4bbea 100644 --- a/console/main.go +++ b/console/main.go @@ -14,6 +14,7 @@ import ( "time" "fission-console/ui" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -79,6 +80,10 @@ func main() { w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = w.Write([]byte("ok\n")) }) + mux.HandleFunc("/console/health", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte("ok\n")) + }) uiHandler := ui.Handler() mux.Handle("/console", uiHandler) @@ -91,6 +96,13 @@ func main() { mux.HandleFunc("/api/httptriggers", s.handleList(httpTrigGVR)) mux.HandleFunc("/api/timetriggers", s.handleList(timeTrigGVR)) + mux.HandleFunc("/console/api/environments", s.handleList(environmentGVR)) + mux.HandleFunc("/console/api/packages", s.handleList(packageGVR)) + mux.HandleFunc("/console/api/functions", s.handleFunctionsRoot) + mux.HandleFunc("/console/api/functions/", s.handleFunctionsAction) + mux.HandleFunc("/console/api/httptriggers", s.handleList(httpTrigGVR)) + mux.HandleFunc("/console/api/timetriggers", s.handleList(timeTrigGVR)) + httpServer := &http.Server{ Addr: ":" + port, Handler: withCORS(logRequests(mux)), @@ -114,6 +126,9 @@ func (s *server) handleFunctionsRoot(w http.ResponseWriter, r *http.Request) { func (s *server) handleFunctionsAction(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/functions/") + if path == r.URL.Path { + path = strings.TrimPrefix(r.URL.Path, "/console/api/functions/") + } path = strings.Trim(path, "/") if path == "" { http.NotFound(w, r) @@ -175,9 +190,10 @@ func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { if req.Route == "" { req.Route = "/" + req.Name } - if len(req.Methods) == 0 { - req.Methods = []string{"GET"} + if !strings.HasPrefix(req.Route, "/") { + req.Route = "/" + req.Route } + req.Methods = normalizeMethods(req.Methods) ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() @@ -189,6 +205,10 @@ func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { pkgName := req.Name + "-pkg" triggerName := req.Name + "-route" + methodValues := make([]any, 0, len(req.Methods)) + for _, method := range req.Methods { + methodValues = append(methodValues, method) + } literal := base64.StdEncoding.EncodeToString([]byte(req.Code)) pkg := &unstructured.Unstructured{Object: map[string]any{ @@ -233,7 +253,7 @@ func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { "StrategyType": "execution", }, "package": map[string]any{ - "packageref": map[string]any{"name": pkgName, "namespace": s.ns}, + "packageref": map[string]any{"name": pkgName, "namespace": s.ns}, "functionName": req.Entrypoint, }, }, @@ -254,7 +274,7 @@ func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { }, "spec": map[string]any{ "relativeurl": req.Route, - "methods": req.Methods, + "methods": methodValues, "createingress": true, "functionref": map[string]any{ "type": "name", @@ -292,7 +312,50 @@ func (s *server) handleGetFunction(w http.ResponseWriter, r *http.Request, name return } - writeAnyJSON(w, http.StatusOK, fn.Object) + packageName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name") + environment, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name") + entrypoint, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "functionName") + + code := "" + if packageName != "" { + pkg, pkgErr := s.dyn.Resource(packageGVR).Namespace(s.ns).Get(ctx, packageName, metav1.GetOptions{}) + if pkgErr == nil { + literal, _, _ := unstructured.NestedString(pkg.Object, "spec", "deployment", "literal") + if literal != "" { + decoded, decErr := base64.StdEncoding.DecodeString(literal) + if decErr == nil { + code = string(decoded) + } + } + } + } + + route := "" + methods := []string{} + triggers, trigErr := s.dyn.Resource(httpTrigGVR).Namespace(s.ns).List(ctx, metav1.ListOptions{}) + if trigErr == nil { + for _, trig := range triggers.Items { + refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name") + if refName != name { + continue + } + route, _, _ = unstructured.NestedString(trig.Object, "spec", "relativeurl") + methods, _, _ = unstructured.NestedStringSlice(trig.Object, "spec", "methods") + break + } + } + + writeAnyJSON(w, http.StatusOK, map[string]any{ + "name": name, + "namespace": s.ns, + "environment": environment, + "package": packageName, + "entrypoint": entrypoint, + "code": code, + "route": route, + "methods": methods, + "raw": fn.Object, + }) } func (s *server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request, name string) { @@ -364,13 +427,53 @@ func (s *server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, na defer cancel() invokeURL := fmt.Sprintf("%s/fission-function/v2/functions/%s", s.routerURL, name) + invokeMethod := http.MethodPost + triggers, err := s.dyn.Resource(httpTrigGVR).Namespace(s.ns).List(ctx, metav1.ListOptions{}) + if err == nil { + for _, trig := range triggers.Items { + refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name") + if refName != name { + continue + } + route, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl") + methods, _, _ := unstructured.NestedStringSlice(trig.Object, "spec", "methods") + hasPost := false + hasGet := false + for _, method := range methods { + m := strings.ToUpper(strings.TrimSpace(method)) + if m == http.MethodPost { + hasPost = true + } + if m == http.MethodGet { + hasGet = true + } + } + if route != "" { + if !strings.HasPrefix(route, "/") { + route = "/" + route + } + invokeURL = s.routerURL + route + if !hasPost && hasGet { + invokeMethod = http.MethodGet + } + break + } + } + } + start := time.Now() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, invokeURL, bytes.NewReader(bodyBytes)) + var invokeBody io.Reader + if invokeMethod == http.MethodPost { + invokeBody = bytes.NewReader(bodyBytes) + } + req, err := http.NewRequestWithContext(ctx, invokeMethod, invokeURL, invokeBody) if err != nil { writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build invoke request: %v", err)) return } - req.Header.Set("Content-Type", "application/json") + if invokeMethod == http.MethodPost { + req.Header.Set("Content-Type", "application/json") + } resp, err := s.http.Do(req) if err != nil { @@ -381,9 +484,9 @@ func (s *server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, na respBody, _ := io.ReadAll(resp.Body) writeAnyJSON(w, http.StatusOK, map[string]any{ - "status": resp.StatusCode, - "latency_ms": time.Since(start).Milliseconds(), - "invoke_url": invokeURL, + "status": resp.StatusCode, + "latency_ms": time.Since(start).Milliseconds(), + "invoke_url": invokeURL, "response_raw": string(respBody), }) } @@ -409,12 +512,12 @@ func (s *server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, na } if err := s.dyn.Resource(functionGVR).Namespace(s.ns).Delete(ctx, name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { - writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete function %q: %v", name, err)) + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete function %q: %v", name, err)) return } if pkgName != "" { - if err := s.dyn.Resource(packageGVR).Namespace(s.ns).Delete(ctx, pkgName, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { + if err := s.dyn.Resource(packageGVR).Namespace(s.ns).Delete(ctx, pkgName, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete package %q: %v", pkgName, err)) return } @@ -506,3 +609,23 @@ func envDefault(key, fallback string) string { } return fallback } + +func normalizeMethods(in []string) []string { + if len(in) == 0 { + return []string{"GET"} + } + out := make([]string, 0, len(in)) + seen := map[string]bool{} + for _, method := range in { + m := strings.ToUpper(strings.TrimSpace(method)) + if m == "" || seen[m] { + continue + } + seen[m] = true + out = append(out, m) + } + if len(out) == 0 { + return []string{"GET"} + } + return out +} diff --git a/console/main_test.go b/console/main_test.go new file mode 100644 index 0000000..36adfe2 --- /dev/null +++ b/console/main_test.go @@ -0,0 +1,143 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" +) + +func newTestServer(objs ...runtime.Object) *server { + listKinds := map[schema.GroupVersionResource]string{ + environmentGVR: "EnvironmentList", + packageGVR: "PackageList", + functionGVR: "FunctionList", + httpTrigGVR: "HTTPTriggerList", + timeTrigGVR: "TimeTriggerList", + } + dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), listKinds, objs...) + return &server{dyn: dyn, ns: "default", routerURL: "http://example.invalid", http: &http.Client{}} +} + +func TestNormalizeMethods(t *testing.T) { + got := normalizeMethods([]string{"get", " POST ", "get", ""}) + if len(got) != 2 || got[0] != "GET" || got[1] != "POST" { + t.Fatalf("unexpected methods: %#v", got) + } + + fallback := normalizeMethods([]string{"", " "}) + if len(fallback) != 1 || fallback[0] != "GET" { + t.Fatalf("expected default GET, got %#v", fallback) + } +} + +func TestCreateFunctionValidation(t *testing.T) { + s := newTestServer() + req := httptest.NewRequest(http.MethodPost, "/api/functions", strings.NewReader(`{"name":""}`)) + rec := httptest.NewRecorder() + + s.handleCreateFunction(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestCreateFunctionSuccessAndGetDetails(t *testing.T) { + env := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "Environment", + "metadata": map[string]any{ + "name": "python", + "namespace": "default", + }, + }} + s := newTestServer(env) + + body := `{"name":"t-fn","environment":"python","code":"def main(ctx):\n return {\"ok\": True}","entrypoint":"main.main","route":"/t-fn","methods":["get"]}` + req := httptest.NewRequest(http.MethodPost, "/api/functions", strings.NewReader(body)) + rec := httptest.NewRecorder() + s.handleCreateFunction(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) + } + + ctx := context.Background() + pkg, err := s.dyn.Resource(packageGVR).Namespace("default").Get(ctx, "t-fn-pkg", metav1.GetOptions{}) + if err != nil { + t.Fatalf("package not created: %v", err) + } + literal, _, _ := unstructured.NestedString(pkg.Object, "spec", "deployment", "literal") + decoded, err := base64.StdEncoding.DecodeString(literal) + if err != nil { + t.Fatalf("decode literal: %v", err) + } + if !strings.Contains(string(decoded), "def main") { + t.Fatalf("unexpected package code: %q", string(decoded)) + } + + getReq := httptest.NewRequest(http.MethodGet, "/api/functions/t-fn", nil) + getRec := httptest.NewRecorder() + s.handleGetFunction(getRec, getReq, "t-fn") + if getRec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", getRec.Code, getRec.Body.String()) + } + + var out map[string]any + if err := json.Unmarshal(getRec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode get response: %v", err) + } + if out["name"] != "t-fn" { + t.Fatalf("unexpected name: %#v", out["name"]) + } + if out["environment"] != "python" { + t.Fatalf("unexpected environment: %#v", out["environment"]) + } +} + +func TestUpdateFunctionCode(t *testing.T) { + env := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "Environment", + "metadata": map[string]any{"name": "python", "namespace": "default"}, + }} + s := newTestServer(env) + + createReq := httptest.NewRequest(http.MethodPost, "/api/functions", strings.NewReader(`{"name":"upd-fn","environment":"python","code":"old","entrypoint":"main.main"}`)) + createRec := httptest.NewRecorder() + s.handleCreateFunction(createRec, createReq) + if createRec.Code != http.StatusCreated { + t.Fatalf("create failed: %d %s", createRec.Code, createRec.Body.String()) + } + + updReq := httptest.NewRequest(http.MethodPut, "/api/functions/upd-fn/code", bytes.NewBufferString(`{"code":"new-code"}`)) + updRec := httptest.NewRecorder() + s.handleUpdateFunctionCode(updRec, updReq, "upd-fn") + if updRec.Code != http.StatusOK { + t.Fatalf("update failed: %d %s", updRec.Code, updRec.Body.String()) + } + + ctx := context.Background() + pkg, err := s.dyn.Resource(packageGVR).Namespace("default").Get(ctx, "upd-fn-pkg", metav1.GetOptions{}) + if err != nil { + t.Fatalf("get package: %v", err) + } + literal, _, _ := unstructured.NestedString(pkg.Object, "spec", "deployment", "literal") + decoded, err := base64.StdEncoding.DecodeString(literal) + if err != nil { + t.Fatalf("decode literal: %v", err) + } + if string(decoded) != "new-code" { + t.Fatalf("expected new-code, got %q", string(decoded)) + } +} diff --git a/console/ui/index.html b/console/ui/index.html index 41c95d9..36e2283 100644 --- a/console/ui/index.html +++ b/console/ui/index.html @@ -47,6 +47,20 @@ padding: 8px 12px; cursor: pointer; font-size: 13px; + line-height: 1.2; + } + + .btn:disabled { + opacity: .6; + cursor: not-allowed; + } + + .btn.danger { + background: #b43232; + } + + .btn.ghost { + background: #0c2b49; } .wrap { @@ -90,6 +104,14 @@ margin-top: 12px; } + .toolbar { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; + margin-bottom: 10px; + } + table { width: 100%; border-collapse: collapse; @@ -108,12 +130,124 @@ font-size: 12px; margin-top: 8px; } + + .mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + } + + .status { + margin-top: 10px; + padding: 9px 10px; + border-radius: 6px; + font-size: 13px; + background: #0c2b49; + border: 1px solid #1d486d; + display: none; + } + + .status.ok { + background: #103625; + border-color: #236843; + } + + .status.err { + background: #411d1d; + border-color: #7a2f2f; + } + + .chip { + display: inline-block; + padding: 2px 7px; + border-radius: 999px; + border: 1px solid #1d486d; + background: #0c2b49; + font-size: 11px; + margin-right: 4px; + } + + .modal { + position: fixed; + inset: 0; + background: rgba(0,0,0,.45); + display: none; + align-items: center; + justify-content: center; + padding: 16px; + } + + .modal.open { display: flex; } + + .panel { + width: min(820px, 100%); + max-height: 90vh; + overflow: auto; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; + } + + .panel h3 { + margin-bottom: 10px; + font-size: 16px; + } + + .row { + display: flex; + gap: 10px; + margin-bottom: 10px; + flex-wrap: wrap; + } + + .field { + flex: 1; + min-width: 180px; + } + + label { + display: block; + margin-bottom: 4px; + font-size: 12px; + color: var(--text-secondary); + } + + input, select, textarea { + width: 100%; + border: 1px solid #1d486d; + background: #03192c; + color: var(--text-primary); + border-radius: 6px; + padding: 8px 10px; + font-size: 13px; + } + + textarea { + min-height: 220px; + resize: vertical; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + } + + .actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 8px; + flex-wrap: wrap; + } + + .nowrap { + white-space: nowrap; + }
@@ -126,40 +260,319 @@
-
Functions (MVP list)
+
+
Functions
+
Actions: view, edit code, invoke, delete
+
- +
NameEnvironmentPackage
NameEnvironmentPackageRouteMethodsActions
-
Initial MVP. Next step: create/edit/invoke/delete.
+
+
Namespace: default. CRUD происходит напрямую через CRD Fission.
+
+ + + + + + + +