diff --git a/internal/api/handler/source.go b/internal/api/handler/source.go
new file mode 100644
index 0000000..44b83b4
--- /dev/null
+++ b/internal/api/handler/source.go
@@ -0,0 +1,119 @@
+// Создано: 2026-04-25
+// source.go — handler для GET /v1/namespaces/{namespace}/functions/{name}/source
+// Возвращает список файлов исходного кода функции из tar.gz контекста сборки (S3).
+// Dockerfile исключается — он сгенерирован builder-ом, не является кодом пользователя.
+// Если код ещё не загружен (S3Key пустой) — возвращает пустой список.
+
+package handler
+
+import (
+ "archive/tar"
+ "compress/gzip"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ "k8s.io/apimachinery/pkg/api/errors"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
+)
+
+// sourceFileEntry — один файл из исходного кода функции.
+type sourceFileEntry struct {
+ Name string `json:"name"`
+ Content string `json:"content"` // UTF-8 текст; бинарные файлы — base64 encoded
+ Binary bool `json:"binary"` // true если файл бинарный (base64 в content)
+}
+
+// GetSource — GET /v1/namespaces/{namespace}/functions/{name}/source
+// Скачивает tar.gz контекст сборки из S3 и возвращает пользовательские файлы (без Dockerfile).
+func (h *Handler) GetSource(w http.ResponseWriter, r *http.Request) {
+ ns := namespace(r)
+ name := pathVar(r, "name")
+
+ fn := &slessv1alpha1.Function{}
+ if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, fn); err != nil {
+ if errors.IsNotFound(err) {
+ writeJSON(w, http.StatusNotFound, errResp("function not found"))
+ return
+ }
+ writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
+ return
+ }
+
+ // Код ещё не был загружен — возвращаем пустой список без ошибки
+ if fn.Spec.S3Key == "" {
+ writeJSON(w, http.StatusOK, []sourceFileEntry{})
+ return
+ }
+
+ rc, err := h.S3.Download(r.Context(), fn.Spec.S3Key)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, errResp("download from S3: "+err.Error()))
+ return
+ }
+ defer rc.Close()
+
+ files, err := extractSourceFilesFromTarGz(rc)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, errResp("extract source: "+err.Error()))
+ return
+ }
+
+ writeJSON(w, http.StatusOK, files)
+}
+
+// extractSourceFilesFromTarGz читает tar.gz и возвращает пользовательские файлы.
+// Dockerfile пропускается — он генерируется builder-ом автоматически.
+// Бинарные файлы (содержат null bytes) возвращаются в base64 с binary=true.
+func extractSourceFilesFromTarGz(r io.Reader) ([]sourceFileEntry, error) {
+ gr, err := gzip.NewReader(r)
+ if err != nil {
+ return nil, fmt.Errorf("gzip: %w", err)
+ }
+ defer gr.Close()
+
+ tr := tar.NewReader(gr)
+ var result []sourceFileEntry
+
+ for {
+ hdr, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, fmt.Errorf("tar next: %w", err)
+ }
+
+ // Dockerfile сгенерирован builder-ом — не показываем пользователю
+ if hdr.Name == "Dockerfile" || hdr.FileInfo().IsDir() {
+ continue
+ }
+
+ data, err := io.ReadAll(tr)
+ if err != nil {
+ return nil, fmt.Errorf("read %s: %w", hdr.Name, err)
+ }
+
+ // Бинарные файлы — base64, текстовые — как есть
+ content := string(data)
+ isBinary := strings.ContainsRune(content, '\x00')
+ if isBinary {
+ content = base64.StdEncoding.EncodeToString(data)
+ }
+
+ result = append(result, sourceFileEntry{
+ Name: hdr.Name,
+ Content: content,
+ Binary: isBinary,
+ })
+ }
+
+ if result == nil {
+ result = []sourceFileEntry{}
+ }
+ return result, nil
+}
diff --git a/internal/api/router.go b/internal/api/router.go
index 7b43251..7cae452 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -1,4 +1,4 @@
-// Изменено: 2026-03-08
+// Изменено: 2026-04-25
// router.go — регистрация всех REST-маршрутов через gorilla/mux.
// Все маршруты защищены Bearer-токеном (middleware.Auth).
// Маршруты сгруппированы по /v1/namespaces/{namespace}/...
@@ -45,6 +45,9 @@ func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler {
// Upload code — принимает zip, генерирует Dockerfile, кладёт tar.gz в S3, запускает сборку
v1.HandleFunc("/namespaces/{namespace}/functions/{name}/upload", h.UploadCode).Methods(http.MethodPost)
+ // Source code — возвращает файлы из tar.gz контекста сборки (без Dockerfile)
+ v1.HandleFunc("/namespaces/{namespace}/functions/{name}/source", h.GetSource).Methods(http.MethodGet)
+
// Triggers CRUD
v1.HandleFunc("/namespaces/{namespace}/triggers", h.ListTriggers).Methods(http.MethodGet)
v1.HandleFunc("/namespaces/{namespace}/triggers", h.CreateTrigger).Methods(http.MethodPost)
diff --git a/services/funcs/Dockerfile b/services/funcs/Dockerfile
index 8633a2e..fc75bee 100644
--- a/services/funcs/Dockerfile
+++ b/services/funcs/Dockerfile
@@ -1,11 +1,10 @@
-# 2026-03-18
+# 2026-04-25 (добавлен index.html — встраивается через go:embed)
# Dockerfile для sless-funcs-service.
# Многоэтапная сборка: Go → alpine (минимальный образ).
-# Сервис не нуждается во внешних зависимостях — только stdlib.
FROM golang:1.23-alpine AS builder
WORKDIR /build
-COPY go.mod main.go ./
+COPY go.mod main.go index.html ./
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o funcs-service .
FROM alpine:3.20
diff --git a/services/funcs/index.html b/services/funcs/index.html
new file mode 100644
index 0000000..02584ba
--- /dev/null
+++ b/services/funcs/index.html
@@ -0,0 +1,492 @@
+
+
+
+
+
+
+ sless console
+
+
+
+
+
+
+
+ sless
+ /
+ …
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/services/funcs/main.go b/services/funcs/main.go
index 9833267..bc33ca3 100644
--- a/services/funcs/main.go
+++ b/services/funcs/main.go
@@ -1,25 +1,29 @@
-// 2026-03-18
+// Изменено: 2026-04-25 (добавлена HTML web-консоль, proxy endpoints для source/triggers)
// main.go — глобальный HTTP сервис листинга функций пользователя.
// Развёрнут ОДИН РАЗ в namespace sless; работает для ВСЕХ пользователей.
// Не связан с terraform — деплоится манифестом deployments/k8s/funcs-service.yaml.
//
-// Логика:
-// 1. Принимает Authorization: Bearer {jwt} от пользователя
-// 2. Извлекает sub из JWT (без проверки подписи — trusted perimeter за Ingress)
-// 3. Вычисляет namespace = SHA256(sub)[:8] hex (та же логика что в операторе)
-// 4. Вызывает внутренний API оператора: GET /v1/namespaces/{ns}/functions + /triggers
-// 5. Возвращает plain text — человекочитаемый список
+// Маршруты:
+// GET /funcs — usage hint (без токена)
+// GET /funcs?token= — листинг через JWT токен
+// GET /funcs/ — листинг (Accept: text/html → HTML, иначе plain text)
+// GET /funcs//source/ — прокси к оператору: файлы исходного кода (JSON)
+// PATCH /funcs//triggers/ — прокси к оператору: enable/disable триггера
+// GET /health — liveness/readiness probe
//
// Env vars:
// SLESS_OPERATOR_URL — URL оператора внутри кластера (default: http://sless-operator.sless.svc.cluster.local:9090)
// SLESS_EXTERNAL_URL — публичный базовый URL для корректных ссылок на функции
// SLESS_EXCLUDE — comma-separated список имён функций, скрытых из листинга
+// SLESS_SERVICE_TOKEN — токен сервиса для запросов к оператору (для /funcs/ без токена юзера)
// PORT — порт сервера (default: 8090)
package main
import (
+ "bytes"
"crypto/sha256"
+ _ "embed"
"encoding/base64"
"encoding/json"
"fmt"
@@ -27,10 +31,14 @@ import (
"log"
"net/http"
"os"
+ "regexp"
"sort"
"strings"
)
+//go:embed index.html
+var htmlPageTemplate string
+
// fnResponse — ответ /v1/namespaces/{ns}/functions (подмножество полей оператора)
type fnResponse struct {
Name string `json:"name"`
@@ -52,11 +60,22 @@ type trResponse struct {
URL string `json:"url"`
}
+// pageData — структура данных для HTML-шаблона web-консоли.
+type pageData struct {
+ Namespace string `json:"namespace"`
+ ExternalURL string `json:"externalURL"`
+ Functions []fnResponse `json:"functions"`
+ TriggersByFn map[string][]trResponse `json:"triggersByFn"`
+}
+
+// nsRegex — базовая валидация kubernetes namespace (ловеркейс + hyphens).
+// Защита от path traversal при подстановке в URL запросов к оператору.
+var nsRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$`)
+
func main() {
operatorURL := strings.TrimRight(env("SLESS_OPERATOR_URL", "http://sless-operator.sless.svc.cluster.local:9090"), "/")
externalURL := strings.TrimRight(env("SLESS_EXTERNAL_URL", ""), "/")
port := env("PORT", "8090")
- // serviceToken — токен сервиса для запросов к оператору по /funcs/ (без токена пользователя)
serviceToken := os.Getenv("SLESS_SERVICE_TOKEN")
exclude := map[string]bool{}
@@ -66,155 +85,335 @@ func main() {
}
}
- http.HandleFunc("/funcs", handler(operatorURL, externalURL, serviceToken, exclude))
- // /health — для liveness/readiness probe без Bearer токена
+ // /funcs (точное совпадение) — namespace из JWT токена (?token= или Authorization header)
+ http.HandleFunc("/funcs", handleFuncsToken(operatorURL, externalURL, exclude))
+ // /funcs/ — namespace в URL пути + sub-paths (source/triggers proxy)
+ http.HandleFunc("/funcs/", handleFuncsNS(operatorURL, externalURL, serviceToken, exclude))
+ // /health — для liveness/readiness probe (без auth)
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintln(w, "ok")
})
- // /funcs/ — без токена, namespace виден в URL всех функций
- http.HandleFunc("/funcs/", handler(operatorURL, externalURL, serviceToken, exclude))
log.Printf("sless-funcs-service listening on :%s (operator: %s)", port, operatorURL)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
-func handler(operatorURL, externalURL, serviceToken string, exclude map[string]bool) http.HandlerFunc {
+// handleFuncsToken обрабатывает GET /funcs с JWT токеном (?token= или Authorization header).
+func handleFuncsToken(operatorURL, externalURL string, exclude map[string]bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- var namespace, authHeader string
-
- // Вариант 1: /funcs/ — namespace прямо в URL, токен не нужен.
- // Namespace виден в URL каждой функции — не является секретом.
- if strings.HasPrefix(r.URL.Path, "/funcs/") {
- namespace = strings.TrimPrefix(r.URL.Path, "/funcs/")
- namespace = strings.Trim(namespace, "/")
- if namespace == "" {
- http.Error(w, "usage: /funcs/\n", http.StatusBadRequest)
- return
- }
- // Используем serviceToken сервиса, если задан
- if serviceToken != "" {
- authHeader = "Bearer " + serviceToken
- }
- // authHeader может остаться пустым — оператор ответит 401 если не настроен
- } else {
- // Вариант 2: ?token= — токен как query param (можно bookmarkнуть URL)
- token := r.URL.Query().Get("token")
- // Вариант 3: Authorization: Bearer — стандартный заголовок
- if token == "" {
- token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
- }
- if token == "" {
- w.Header().Set("Content-Type", "text/plain; charset=utf-8")
- w.WriteHeader(http.StatusUnauthorized)
- fmt.Fprintf(w, "Укажите namespace в URL или передайте токен:\n\n")
- fmt.Fprintf(w, " %s/funcs/\n", externalURL)
- fmt.Fprintf(w, " %s/funcs?token=\n", externalURL)
- return
- }
- sub, err := subFromJWT(token)
- if err != nil {
- http.Error(w, fmt.Sprintf("invalid token: %s\n", err), http.StatusUnauthorized)
- return
- }
- namespace = namespaceFromSub(sub)
- authHeader = "Bearer " + token
+ token := r.URL.Query().Get("token")
+ if token == "" {
+ token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
-
- fns, err := apiGet[[]fnResponse](operatorURL+"/v1/namespaces/"+namespace+"/functions", authHeader)
- if err != nil {
- http.Error(w, fmt.Sprintf("operator error (functions): %s\n", err), http.StatusBadGateway)
+ if token == "" {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.WriteHeader(http.StatusUnauthorized)
+ fmt.Fprintln(w, "Укажите namespace в URL или передайте токен:")
+ fmt.Fprintln(w, "")
+ fmt.Fprintf(w, " %s/funcs/\n", externalURL)
+ fmt.Fprintf(w, " %s/funcs?token=\n", externalURL)
return
}
- trs, err := apiGet[[]trResponse](operatorURL+"/v1/namespaces/"+namespace+"/triggers", authHeader)
+ sub, err := subFromJWT(token)
if err != nil {
- http.Error(w, fmt.Sprintf("operator error (triggers): %s\n", err), http.StatusBadGateway)
+ http.Error(w, fmt.Sprintf("invalid token: %s\n", err), http.StatusUnauthorized)
return
}
-
- // Индекс триггеров по имени функции
- trigIdx := map[string][]trResponse{}
- for _, tr := range trs {
- trigIdx[tr.FunctionRef] = append(trigIdx[tr.FunctionRef], tr)
- }
-
- // Фильтрация и сортировка: активные вверх, потом по имени
- type entry struct {
- fn fnResponse
- httpT []trResponse
- cronT []trResponse
- isActive bool
- }
- var items []entry
- for _, fn := range fns {
- if exclude[fn.Name] {
- continue
- }
- var httpT, cronT []trResponse
- isActive := false
- for _, tr := range trigIdx[fn.Name] {
- switch tr.Type {
- case "http":
- httpT = append(httpT, tr)
- case "cron":
- cronT = append(cronT, tr)
- }
- if tr.Enabled && tr.Active {
- isActive = true
- }
- }
- items = append(items, entry{fn, httpT, cronT, isActive})
- }
- sort.Slice(items, func(i, j int) bool {
- if items[i].isActive != items[j].isActive {
- return items[i].isActive
- }
- return items[i].fn.Name < items[j].fn.Name
- })
-
- sep := strings.Repeat("─", 52)
- var sb strings.Builder
- for _, it := range items {
- fn := it.fn
- sb.WriteString(sep + "\n")
- sb.WriteString(" " + buildComment(fn, it.httpT, it.cronT) + "\n")
- sb.WriteString(fmt.Sprintf(" name: %s\n", fn.Name))
- sb.WriteString(fmt.Sprintf(" runtime: %s\n", fn.Runtime))
- sb.WriteString(fmt.Sprintf(" phase: %s\n", fn.Phase))
- if it.isActive {
- sb.WriteString(" active: да\n")
- } else {
- sb.WriteString(" active: нет\n")
- }
- if len(it.httpT) > 0 {
- url := it.httpT[0].URL
- if externalURL != "" {
- url = externalURL + "/fn/" + namespace + "/" + fn.Name
- }
- sb.WriteString(fmt.Sprintf(" url: %s\n", url))
- }
- if len(it.cronT) > 0 {
- sb.WriteString(fmt.Sprintf(" cron: %s\n", it.cronT[0].Schedule))
- }
- if fn.CreatedAt != "" {
- sb.WriteString(fmt.Sprintf(" created: %s\n", fn.CreatedAt))
- }
- if fn.LastBuiltAt != "" {
- sb.WriteString(fmt.Sprintf(" built: %s\n", fn.LastBuiltAt))
- }
- if fn.Message != "" {
- sb.WriteString(fmt.Sprintf(" message: %s\n", fn.Message))
- }
- }
- sb.WriteString(sep + "\n")
- sb.WriteString(fmt.Sprintf(" namespace: %s | total: %d\n", namespace, len(items)))
- sb.WriteString(sep + "\n")
-
- w.Header().Set("Content-Type", "text/plain; charset=utf-8")
- fmt.Fprint(w, sb.String())
+ fetchAndRender(w, r, operatorURL, externalURL, "Bearer "+token, namespaceFromSub(sub), exclude)
}
}
+// handleFuncsNS обрабатывает все запросы /funcs/{ns}[/sub/path].
+//
+// Маршруты:
+//
+// GET /funcs/{ns} — листинг (HTML если Accept: text/html, иначе plain text)
+// GET /funcs/{ns}/source/{fn} — прокси: файлы исходного кода функции
+// PATCH /funcs/{ns}/triggers/{name} — прокси: enable/disable триггер
+func handleFuncsNS(operatorURL, externalURL, serviceToken string, exclude map[string]bool) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ rest := strings.TrimPrefix(r.URL.Path, "/funcs/")
+ rest = strings.Trim(rest, "/")
+ if rest == "" {
+ http.Error(w, "usage: /funcs/\n", http.StatusBadRequest)
+ return
+ }
+
+ // SplitN до 3 частей: [ns], [ns, subtype] или [ns, subtype, resourceName]
+ parts := strings.SplitN(rest, "/", 3)
+ ns := parts[0]
+
+ if !nsRegex.MatchString(ns) {
+ http.Error(w, "invalid namespace\n", http.StatusBadRequest)
+ return
+ }
+
+ if len(parts) == 3 {
+ switch parts[1] {
+ case "source":
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
+ return
+ }
+ proxySourceGet(w, r, operatorURL, serviceToken, ns, parts[2])
+ return
+ case "triggers":
+ if r.Method != http.MethodPatch {
+ http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
+ return
+ }
+ proxyTriggerPatch(w, r, operatorURL, serviceToken, ns, parts[2])
+ return
+ }
+ }
+
+ if len(parts) > 1 {
+ http.NotFound(w, r)
+ return
+ }
+
+ // GET /funcs/{ns} — листинг с авторизацией через serviceToken
+ authHeader := ""
+ if serviceToken != "" {
+ authHeader = "Bearer " + serviceToken
+ }
+ fetchAndRender(w, r, operatorURL, externalURL, authHeader, ns, exclude)
+ }
+}
+
+// fetchAndRender загружает данные из оператора и отдаёт ответ: HTML (браузер) или plain text (curl).
+func fetchAndRender(w http.ResponseWriter, r *http.Request, operatorURL, externalURL, authHeader, ns string, exclude map[string]bool) {
+ fns, err := apiGet[[]fnResponse](operatorURL+"/v1/namespaces/"+ns+"/functions", authHeader)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("operator error (functions): %s\n", err), http.StatusBadGateway)
+ return
+ }
+ trs, err := apiGet[[]trResponse](operatorURL+"/v1/namespaces/"+ns+"/triggers", authHeader)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("operator error (triggers): %s\n", err), http.StatusBadGateway)
+ return
+ }
+ if acceptsHTML(r) {
+ serveHTML(w, ns, externalURL, fns, trs, exclude)
+ } else {
+ servePlainText(w, ns, externalURL, fns, trs, exclude)
+ }
+}
+
+// acceptsHTML возвращает true если клиент предпочитает HTML (браузер).
+// curl без -H "Accept: text/html" получит plain text — поведение совместимо с v0.1.x.
+func acceptsHTML(r *http.Request) bool {
+ return strings.Contains(r.Header.Get("Accept"), "text/html")
+}
+
+// serveHTML рендерит web-консоль.
+// Данные встраиваются как JSON в в данных экранируется как <\/script> (валидный JSON escape).
+func serveHTML(w http.ResponseWriter, ns, externalURL string, fns []fnResponse, trs []trResponse, exclude map[string]bool) {
+ trigIdx := buildTriggerIndex(trs)
+ filtered := filterAndSort(fns, trigIdx, exclude)
+
+ pd := pageData{
+ Namespace: ns,
+ ExternalURL: externalURL,
+ Functions: filtered,
+ TriggersByFn: trigIdx,
+ }
+
+ raw, _ := json.Marshal(pd)
+ // Предотвращаем преждевременное закрытие тега