feat: REST API (gorilla/mux + slog) + trigger controller + main.go wiring
- controllers/trigger_controller.go: полная реализация, HTTP->Ingress+Service, Cron->CronJob
- internal/config: добавлены IngressHost, APIToken
- internal/api/router.go: gorilla/mux роутер /v1/namespaces/{ns}/...
- internal/api/handler/: functions, triggers, invocations, base handler с slog
- internal/api/middleware/: auth (Bearer token) + logging (slog)
- main.go: запуск operator + HTTP сервера параллельно
- go.sum: добавлен gorilla/mux v1.8.1
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
// Изменено: 2026-03-07
|
||||
// functions.go — CRUD handlers для Function CRD.
|
||||
// Принимает JSON, создаёт/обновляет/удаляет k8s ресурсы Function.
|
||||
// Namespace берётся из URL: /v1/namespaces/{namespace}/functions/{name}
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
|
||||
)
|
||||
|
||||
// functionRequest — тело запроса для создания/обновления функции.
|
||||
type functionRequest struct {
|
||||
Name string `json:"name"`
|
||||
Runtime string `json:"runtime"`
|
||||
Entrypoint string `json:"entrypoint"`
|
||||
MemoryMB int32 `json:"memory_mb"`
|
||||
TimeoutSec int32 `json:"timeout_sec"`
|
||||
Env map[string]string `json:"env_vars"`
|
||||
S3Bucket string `json:"s3_bucket"`
|
||||
S3Key string `json:"s3_key"`
|
||||
}
|
||||
|
||||
// functionResponse — ответ при чтении функции.
|
||||
type functionResponse struct {
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
Runtime string `json:"runtime"`
|
||||
Entrypoint string `json:"entrypoint"`
|
||||
MemoryMB int32 `json:"memory_mb"`
|
||||
TimeoutSec int32 `json:"timeout_sec"`
|
||||
Env map[string]string `json:"env_vars"`
|
||||
S3Bucket string `json:"s3_bucket"`
|
||||
S3Key string `json:"s3_key"`
|
||||
Phase slessv1alpha1.FunctionPhase `json:"phase"`
|
||||
ImageRef string `json:"image_ref"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// fnToResponse конвертирует CRD в ответ API.
|
||||
func fnToResponse(fn *slessv1alpha1.Function) functionResponse {
|
||||
return functionResponse{
|
||||
Name: fn.Name,
|
||||
Namespace: fn.Namespace,
|
||||
Runtime: fn.Spec.Runtime,
|
||||
Entrypoint: fn.Spec.Entrypoint,
|
||||
MemoryMB: fn.Spec.MemoryMB,
|
||||
TimeoutSec: fn.Spec.TimeoutSec,
|
||||
Env: fn.Spec.Env,
|
||||
S3Bucket: fn.Spec.S3Bucket,
|
||||
S3Key: fn.Spec.S3Key,
|
||||
Phase: fn.Status.Phase,
|
||||
ImageRef: fn.Status.ImageRef,
|
||||
Message: fn.Status.Message,
|
||||
}
|
||||
}
|
||||
|
||||
// ListFunctions — GET /v1/namespaces/{namespace}/functions
|
||||
func (h *Handler) ListFunctions(w http.ResponseWriter, r *http.Request) {
|
||||
ns := namespace(r)
|
||||
list := &slessv1alpha1.FunctionList{}
|
||||
if err := h.K8s.List(r.Context(), list, client.InNamespace(ns)); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
||||
return
|
||||
}
|
||||
result := make([]functionResponse, 0, len(list.Items))
|
||||
for i := range list.Items {
|
||||
result = append(result, fnToResponse(&list.Items[i]))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// CreateFunction — POST /v1/namespaces/{namespace}/functions
|
||||
func (h *Handler) CreateFunction(w http.ResponseWriter, r *http.Request) {
|
||||
ns := namespace(r)
|
||||
var req functionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.Runtime == "" {
|
||||
writeJSON(w, http.StatusBadRequest, errResp("name and runtime are required"))
|
||||
return
|
||||
}
|
||||
|
||||
fn := &slessv1alpha1.Function{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: req.Name,
|
||||
Namespace: ns,
|
||||
},
|
||||
Spec: slessv1alpha1.FunctionSpec{
|
||||
Runtime: req.Runtime,
|
||||
Entrypoint: req.Entrypoint,
|
||||
MemoryMB: req.MemoryMB,
|
||||
TimeoutSec: req.TimeoutSec,
|
||||
Env: req.Env,
|
||||
S3Bucket: req.S3Bucket,
|
||||
S3Key: req.S3Key,
|
||||
},
|
||||
}
|
||||
if err := h.K8s.Create(r.Context(), fn); err != nil {
|
||||
if errors.IsAlreadyExists(err) {
|
||||
writeJSON(w, http.StatusConflict, errResp("function already exists"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, fnToResponse(fn))
|
||||
}
|
||||
|
||||
// GetFunction — GET /v1/namespaces/{namespace}/functions/{name}
|
||||
func (h *Handler) GetFunction(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
|
||||
}
|
||||
writeJSON(w, http.StatusOK, fnToResponse(fn))
|
||||
}
|
||||
|
||||
// UpdateFunction — PUT /v1/namespaces/{namespace}/functions/{name}
|
||||
func (h *Handler) UpdateFunction(w http.ResponseWriter, r *http.Request) {
|
||||
ns := namespace(r)
|
||||
name := pathVar(r, "name")
|
||||
var req functionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Обновляем только изменяемые поля spec
|
||||
fn.Spec.Runtime = req.Runtime
|
||||
fn.Spec.Entrypoint = req.Entrypoint
|
||||
fn.Spec.MemoryMB = req.MemoryMB
|
||||
fn.Spec.TimeoutSec = req.TimeoutSec
|
||||
fn.Spec.Env = req.Env
|
||||
if req.S3Bucket != "" {
|
||||
fn.Spec.S3Bucket = req.S3Bucket
|
||||
}
|
||||
if req.S3Key != "" {
|
||||
fn.Spec.S3Key = req.S3Key
|
||||
}
|
||||
|
||||
if err := h.K8s.Update(r.Context(), fn); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, fnToResponse(fn))
|
||||
}
|
||||
|
||||
// DeleteFunction — DELETE /v1/namespaces/{namespace}/functions/{name}
|
||||
func (h *Handler) DeleteFunction(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) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.K8s.Delete(r.Context(), fn); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Изменено: 2026-03-07
|
||||
// Handler — общий контейнер зависимостей для всех REST handlers.
|
||||
// Все handlers получают доступ к k8s, S3 и Postgres через эту структуру.
|
||||
// Логирование через slog, маршрутизация через gorilla/mux.
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/postgres"
|
||||
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/s3"
|
||||
)
|
||||
|
||||
// Handler содержит зависимости для всех REST-обработчиков.
|
||||
type Handler struct {
|
||||
K8s client.Client
|
||||
Scheme *runtime.Scheme
|
||||
S3 *s3.Client
|
||||
PG *postgres.Store
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// writeJSON отправляет JSON-ответ с указанным статусом.
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// errResp возвращает структуру ошибки для JSON.
|
||||
func errResp(msg string) map[string]string {
|
||||
return map[string]string{"error": msg}
|
||||
}
|
||||
|
||||
// pathVar читает переменную пути из gorilla/mux.
|
||||
func pathVar(r *http.Request, key string) string {
|
||||
return mux.Vars(r)[key]
|
||||
}
|
||||
|
||||
// namespace читает {namespace} из пути, fallback — "default".
|
||||
func namespace(r *http.Request) string {
|
||||
if ns := mux.Vars(r)["namespace"]; ns != "" {
|
||||
return ns
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Изменено: 2026-03-07
|
||||
// invocations.go — GET-handlers для логов вызовов функций.
|
||||
// Данные читаются из PostgreSQL (invocations таблица).
|
||||
// POST /invoke остаётся для будущего прямого вызова (v2).
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// invocationResponse — ответ при чтении записи вызова.
|
||||
type invocationResponse struct {
|
||||
ID string `json:"id"`
|
||||
FunctionName string `json:"function_name"`
|
||||
Namespace string `json:"namespace"`
|
||||
Status string `json:"status"`
|
||||
DurationMs int32 `json:"duration_ms"`
|
||||
HTTPStatus *int32 `json:"http_status,omitempty"` // nil для cron триггеров
|
||||
Logs string `json:"logs,omitempty"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListInvocations — GET /v1/namespaces/{namespace}/functions/{name}/invocations
|
||||
// Возвращает последние limit вызовов (default 50, max 200).
|
||||
func (h *Handler) ListInvocations(w http.ResponseWriter, r *http.Request) {
|
||||
ns := namespace(r)
|
||||
fnName := pathVar(r, "name")
|
||||
|
||||
limit := 50
|
||||
if q := r.URL.Query().Get("limit"); q != "" {
|
||||
if v, err := strconv.Atoi(q); err == nil && v > 0 && v <= 200 {
|
||||
limit = v
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := h.PG.ListInvocations(r.Context(), fnName, ns, limit)
|
||||
if err != nil {
|
||||
h.Log.Error("list invocations", "fn", fnName, "ns", ns, "err", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]invocationResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
result = append(result, invocationResponse{
|
||||
ID: row.ID,
|
||||
FunctionName: row.FunctionName,
|
||||
Namespace: row.Namespace,
|
||||
Status: row.Status,
|
||||
DurationMs: row.DurationMs,
|
||||
HTTPStatus: row.HTTPStatus,
|
||||
Logs: row.Logs,
|
||||
TriggerType: row.TriggerType,
|
||||
CreatedAt: row.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Изменено: 2026-03-07
|
||||
// triggers.go — CRUD handlers для Trigger CRD.
|
||||
// Триггеры привязаны к Function через FunctionRef.
|
||||
// Namespace берётся из URL: /v1/namespaces/{namespace}/triggers/{name}
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
|
||||
)
|
||||
|
||||
// triggerRequest — тело запроса для создания триггера.
|
||||
type triggerRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // http | cron
|
||||
FunctionRef string `json:"function"` // имя Function CRD
|
||||
Schedule string `json:"schedule"` // cron-расписание, только для type=cron
|
||||
PreWarmSeconds int32 `json:"pre_warm_seconds"`
|
||||
}
|
||||
|
||||
// triggerResponse — ответ при чтении триггера.
|
||||
type triggerResponse struct {
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
Type string `json:"type"`
|
||||
FunctionRef string `json:"function"`
|
||||
Schedule string `json:"schedule,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// trToResponse конвертирует Trigger CRD в ответ API.
|
||||
func trToResponse(tr *slessv1alpha1.Trigger) triggerResponse {
|
||||
return triggerResponse{
|
||||
Name: tr.Name,
|
||||
Namespace: tr.Namespace,
|
||||
Type: string(tr.Spec.Type),
|
||||
FunctionRef: tr.Spec.FunctionRef,
|
||||
Schedule: tr.Spec.Schedule,
|
||||
Active: tr.Status.Active,
|
||||
URL: tr.Status.URL,
|
||||
Message: tr.Status.Message,
|
||||
}
|
||||
}
|
||||
|
||||
// ListTriggers — GET /v1/namespaces/{namespace}/triggers
|
||||
func (h *Handler) ListTriggers(w http.ResponseWriter, r *http.Request) {
|
||||
ns := namespace(r)
|
||||
list := &slessv1alpha1.TriggerList{}
|
||||
if err := h.K8s.List(r.Context(), list, client.InNamespace(ns)); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
||||
return
|
||||
}
|
||||
result := make([]triggerResponse, 0, len(list.Items))
|
||||
for i := range list.Items {
|
||||
result = append(result, trToResponse(&list.Items[i]))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// CreateTrigger — POST /v1/namespaces/{namespace}/triggers
|
||||
func (h *Handler) CreateTrigger(w http.ResponseWriter, r *http.Request) {
|
||||
ns := namespace(r)
|
||||
var req triggerRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.Type == "" || req.FunctionRef == "" {
|
||||
writeJSON(w, http.StatusBadRequest, errResp("name, type and function are required"))
|
||||
return
|
||||
}
|
||||
if req.Type == "cron" && req.Schedule == "" {
|
||||
writeJSON(w, http.StatusBadRequest, errResp("schedule is required for cron trigger"))
|
||||
return
|
||||
}
|
||||
|
||||
tr := &slessv1alpha1.Trigger{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: req.Name,
|
||||
Namespace: ns,
|
||||
},
|
||||
Spec: slessv1alpha1.TriggerSpec{
|
||||
Type: slessv1alpha1.TriggerType(req.Type),
|
||||
FunctionRef: req.FunctionRef,
|
||||
Schedule: req.Schedule,
|
||||
PreWarmSeconds: req.PreWarmSeconds,
|
||||
},
|
||||
}
|
||||
if err := h.K8s.Create(r.Context(), tr); err != nil {
|
||||
if errors.IsAlreadyExists(err) {
|
||||
writeJSON(w, http.StatusConflict, errResp("trigger already exists"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, trToResponse(tr))
|
||||
}
|
||||
|
||||
// GetTrigger — GET /v1/namespaces/{namespace}/triggers/{name}
|
||||
func (h *Handler) GetTrigger(w http.ResponseWriter, r *http.Request) {
|
||||
ns := namespace(r)
|
||||
name := pathVar(r, "name")
|
||||
tr := &slessv1alpha1.Trigger{}
|
||||
if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, tr); err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
writeJSON(w, http.StatusNotFound, errResp("trigger not found"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, trToResponse(tr))
|
||||
}
|
||||
|
||||
// DeleteTrigger — DELETE /v1/namespaces/{namespace}/triggers/{name}
|
||||
func (h *Handler) DeleteTrigger(w http.ResponseWriter, r *http.Request) {
|
||||
ns := namespace(r)
|
||||
name := pathVar(r, "name")
|
||||
tr := &slessv1alpha1.Trigger{}
|
||||
if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, tr); err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.K8s.Delete(r.Context(), tr); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
Reference in New Issue
Block a user