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 + + + + + + +
+ + / + + +
+
+

+
+
+ + + + + + \ 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) + // Предотвращаем преждевременное закрытие тега