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)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Изменено: 2026-03-07
|
||||
// Auth middleware — проверяет Bearer токен из заголовка Authorization.
|
||||
// В v1: сравниваем с SLESS_API_TOKEN из конфига.
|
||||
// В prod: вызываем auth-сервис nubes.ru (TODO v2).
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Auth возвращает middleware которое требует заголовок:
|
||||
//
|
||||
// Authorization: Bearer <token>
|
||||
//
|
||||
// и сравнивает его с allowedToken.
|
||||
func Auth(allowedToken string, log *slog.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
header := r.Header.Get("Authorization")
|
||||
if header == "" {
|
||||
log.Warn("auth: missing authorization header", "remote", r.RemoteAddr, "path", r.URL.Path)
|
||||
http.Error(w, `{"error":"authorization required"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(header, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
|
||||
log.Warn("auth: invalid authorization format", "remote", r.RemoteAddr)
|
||||
http.Error(w, `{"error":"invalid authorization format, use Bearer <token>"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if parts[1] != allowedToken {
|
||||
log.Warn("auth: invalid token", "remote", r.RemoteAddr, "path", r.URL.Path)
|
||||
http.Error(w, `{"error":"invalid token"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Изменено: 2026-03-07
|
||||
// Logging middleware — логирует каждый HTTP запрос через slog.
|
||||
// Записывает метод, путь, статус и длительность.
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// responseWriter оборачивает http.ResponseWriter чтобы захватить статус ответа.
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.status = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Logging возвращает middleware которое логирует все запросы через slog.
|
||||
func Logging(log *slog.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rw, r)
|
||||
log.Info("http",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", rw.status,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"remote", r.RemoteAddr,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Изменено: 2026-03-07
|
||||
// router.go — регистрация всех REST-маршрутов через gorilla/mux.
|
||||
// Все маршруты защищены Bearer-токеном (middleware.Auth).
|
||||
// Маршруты сгруппированы по /v1/namespaces/{namespace}/...
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/api/handler"
|
||||
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/api/middleware"
|
||||
)
|
||||
|
||||
// NewRouter собирает gorilla/mux роутер со всеми маршрутами.
|
||||
// apiToken — статический Bearer-токен для v1 аутентификации.
|
||||
func NewRouter(h *handler.Handler, apiToken string, log *slog.Logger) http.Handler {
|
||||
r := mux.NewRouter()
|
||||
|
||||
// Суброутер для /v1 — все маршруты API
|
||||
v1 := r.PathPrefix("/v1").Subrouter()
|
||||
|
||||
// Functions CRUD
|
||||
v1.HandleFunc("/namespaces/{namespace}/functions", h.ListFunctions).Methods(http.MethodGet)
|
||||
v1.HandleFunc("/namespaces/{namespace}/functions", h.CreateFunction).Methods(http.MethodPost)
|
||||
v1.HandleFunc("/namespaces/{namespace}/functions/{name}", h.GetFunction).Methods(http.MethodGet)
|
||||
v1.HandleFunc("/namespaces/{namespace}/functions/{name}", h.UpdateFunction).Methods(http.MethodPut)
|
||||
v1.HandleFunc("/namespaces/{namespace}/functions/{name}", h.DeleteFunction).Methods(http.MethodDelete)
|
||||
|
||||
// Invocation logs
|
||||
v1.HandleFunc("/namespaces/{namespace}/functions/{name}/invocations", h.ListInvocations).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)
|
||||
v1.HandleFunc("/namespaces/{namespace}/triggers/{name}", h.GetTrigger).Methods(http.MethodGet)
|
||||
v1.HandleFunc("/namespaces/{namespace}/triggers/{name}", h.DeleteTrigger).Methods(http.MethodDelete)
|
||||
|
||||
// Цепочка middleware: logging → auth → router
|
||||
// Порядок важен: сначала логируем (чтобы видеть все запросы включая отклонённые),
|
||||
// затем проверяем авторизацию.
|
||||
return middleware.Logging(log,
|
||||
middleware.Auth(apiToken, log, r),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Изменено: 2026-03-07
|
||||
// Builder — запускает kaniko Job в k8s для сборки Docker образа из кода функции.
|
||||
// Почему kaniko, а не docker-in-docker (DinD):
|
||||
// kaniko не требует privileged контейнер, что безопаснее в managed кластере.
|
||||
// kaniko читает контекст сборки из S3 напрямую.
|
||||
// Workflow: FunctionController вызывает Build → Job запускается → образ пушится в registry.
|
||||
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
)
|
||||
|
||||
// Builder — управляет сборкой Docker образов через kaniko Jobs в k8s.
|
||||
type Builder struct {
|
||||
client client.Client
|
||||
builderImage string // образ kaniko
|
||||
registryHost string // куда пушим образ
|
||||
s3Endpoint string // откуда kaniko берёт код
|
||||
s3AccessKey string
|
||||
s3SecretKey string
|
||||
s3Bucket string
|
||||
namespace string // namespace где запускаем build Job'ы
|
||||
}
|
||||
|
||||
// Config — параметры для создания Builder'а.
|
||||
type Config struct {
|
||||
BuilderImage string
|
||||
RegistryHost string
|
||||
S3Endpoint string
|
||||
S3AccessKey string
|
||||
S3SecretKey string
|
||||
S3Bucket string
|
||||
Namespace string
|
||||
}
|
||||
|
||||
// New создаёт новый Builder.
|
||||
func New(c client.Client, cfg Config) *Builder {
|
||||
return &Builder{
|
||||
client: c,
|
||||
builderImage: cfg.BuilderImage,
|
||||
registryHost: cfg.RegistryHost,
|
||||
s3Endpoint: cfg.S3Endpoint,
|
||||
s3AccessKey: cfg.S3AccessKey,
|
||||
s3SecretKey: cfg.S3SecretKey,
|
||||
s3Bucket: cfg.S3Bucket,
|
||||
namespace: cfg.Namespace,
|
||||
}
|
||||
}
|
||||
|
||||
// ImageRef возвращает полный путь к образу в registry для данной функции и версии.
|
||||
func (b *Builder) ImageRef(namespace, funcName, s3Key string) string {
|
||||
// Используем s3Key как уникальный тег чтобы разные версии не перезаписывали друг друга
|
||||
return fmt.Sprintf("%s/sless-%s-%s:latest", b.registryHost, namespace, funcName)
|
||||
}
|
||||
|
||||
// Build запускает kaniko Job для сборки образа функции.
|
||||
// Возвращает имя Job'а чтобы контроллер мог следить за его статусом.
|
||||
func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string) (string, error) {
|
||||
imageRef := b.ImageRef(namespace, funcName, s3Key)
|
||||
jobName := fmt.Sprintf("build-%s-%s", funcName, time.Now().Format("20060102150405"))
|
||||
|
||||
// kaniko читает Dockerfile из context архива в S3
|
||||
// --context=s3://bucket/key — kaniko поддерживает S3 как источник контекста
|
||||
s3ContextURL := fmt.Sprintf("s3://%s/%s", b.s3Bucket, s3Key)
|
||||
|
||||
job := &batchv1.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: jobName,
|
||||
Namespace: b.namespace,
|
||||
Labels: map[string]string{
|
||||
"app": "sless-builder",
|
||||
"function-name": funcName,
|
||||
"function-ns": namespace,
|
||||
},
|
||||
},
|
||||
Spec: batchv1.JobSpec{
|
||||
// Не повторяем при ошибке — контроллер сам перезапустит reconcile
|
||||
BackoffLimit: int32Ptr(0),
|
||||
Completions: int32Ptr(1),
|
||||
Template: corev1.PodTemplateSpec{
|
||||
Spec: corev1.PodSpec{
|
||||
RestartPolicy: corev1.RestartPolicyNever,
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "kaniko",
|
||||
Image: b.builderImage,
|
||||
Args: []string{
|
||||
"--context=" + s3ContextURL,
|
||||
"--destination=" + imageRef,
|
||||
"--skip-tls-verify", // для внутреннего registry без TLS
|
||||
"--cache=true", // кешируем слои для ускорения повторных сборок
|
||||
},
|
||||
Env: []corev1.EnvVar{
|
||||
{Name: "AWS_ACCESS_KEY_ID", Value: b.s3AccessKey},
|
||||
{Name: "AWS_SECRET_ACCESS_KEY", Value: b.s3SecretKey},
|
||||
{Name: "S3_ENDPOINT", Value: b.s3Endpoint},
|
||||
// Kaniko использует AWS SDK совместимый с S3 — задаём кастомный endpoint
|
||||
{Name: "AWS_REGION", Value: "us-east-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := b.client.Create(ctx, job); err != nil {
|
||||
return "", fmt.Errorf("create build job: %w", err)
|
||||
}
|
||||
return jobName, nil
|
||||
}
|
||||
|
||||
// JobStatus проверяет статус build Job'а.
|
||||
// Возвращает: "running", "succeeded", "failed"
|
||||
func (b *Builder) JobStatus(ctx context.Context, jobName string) (string, error) {
|
||||
job := &batchv1.Job{}
|
||||
if err := b.client.Get(ctx, client.ObjectKey{Name: jobName, Namespace: b.namespace}, job); err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
return "failed", nil
|
||||
}
|
||||
return "", fmt.Errorf("get build job: %w", err)
|
||||
}
|
||||
if job.Status.Succeeded > 0 {
|
||||
return "succeeded", nil
|
||||
}
|
||||
if job.Status.Failed > 0 {
|
||||
return "failed", nil
|
||||
}
|
||||
return "running", nil
|
||||
}
|
||||
|
||||
// Cleanup удаляет завершённый build Job из k8s.
|
||||
// Вызывается после того как контроллер зафиксировал результат сборки.
|
||||
func (b *Builder) Cleanup(ctx context.Context, jobName string) error {
|
||||
job := &batchv1.Job{}
|
||||
if err := b.client.Get(ctx, client.ObjectKey{Name: jobName, Namespace: b.namespace}, job); err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
return nil // уже удалён
|
||||
}
|
||||
return fmt.Errorf("get build job for cleanup: %w", err)
|
||||
}
|
||||
propagation := metav1.DeletePropagationForeground
|
||||
return b.client.Delete(ctx, job, &client.DeleteOptions{PropagationPolicy: &propagation})
|
||||
}
|
||||
|
||||
func int32Ptr(i int32) *int32 { return &i }
|
||||
@@ -0,0 +1,122 @@
|
||||
// Изменено: 2026-03-07
|
||||
// Конфигурация сервиса — читается из env переменных при старте.
|
||||
// Все компоненты (API, builder, runner) получают конфиг через эту структуру.
|
||||
// Используем env а не файлы конфигурации — стандарт для k8s (ConfigMap/Secret → env).
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Config — централизованная конфигурация всего сервиса.
|
||||
type Config struct {
|
||||
// APIPort — порт на котором слушает REST API сервер (default: 8080)
|
||||
APIPort int
|
||||
|
||||
// PostgreSQL — параметры подключения к БД для хранения логов вызовов
|
||||
PostgresDSN string
|
||||
|
||||
// S3 — параметры подключения к Ceph S3 для хранения кода функций
|
||||
S3Endpoint string
|
||||
S3AccessKey string
|
||||
S3SecretKey string
|
||||
S3Bucket string
|
||||
S3UseSSL bool
|
||||
|
||||
// Registry — адрес внутреннего Docker registry куда пушим собранные образы
|
||||
RegistryHost string
|
||||
|
||||
// FunctionNamespace — префикс namespace для функций пользователей.
|
||||
// Итоговый namespace: FunctionNamespacePrefix + "-" + functionName
|
||||
FunctionNamespacePrefix string
|
||||
|
||||
// BuilderImage — образ для сборки функций (kaniko или buildah)
|
||||
BuilderImage string
|
||||
|
||||
// IngressHost — базовый домен для HTTP триггеров, например: fn.kube5s.ru
|
||||
// URL функции будет: https://{funcName}-{namespace}.{IngressHost}
|
||||
IngressHost string
|
||||
|
||||
// APIToken — статический Bearer-токен для v1 REST API аутентификации.
|
||||
// В prod заменить на вызов auth-сервиса.
|
||||
APIToken string
|
||||
}
|
||||
|
||||
// Load читает конфиг из env переменных.
|
||||
// Возвращает ошибку если обязательные переменные не заданы.
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
APIPort: 8080,
|
||||
S3UseSSL: false,
|
||||
FunctionNamespacePrefix: "sless-fn",
|
||||
BuilderImage: "gcr.io/kaniko-project/executor:latest",
|
||||
}
|
||||
|
||||
// Опциональный порт API
|
||||
if v := os.Getenv("API_PORT"); v != "" {
|
||||
port, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("API_PORT must be a number: %w", err)
|
||||
}
|
||||
cfg.APIPort = port
|
||||
}
|
||||
|
||||
// Обязательные параметры PostgreSQL
|
||||
cfg.PostgresDSN = os.Getenv("POSTGRES_DSN")
|
||||
if cfg.PostgresDSN == "" {
|
||||
return nil, fmt.Errorf("POSTGRES_DSN is required")
|
||||
}
|
||||
|
||||
// Обязательные параметры S3
|
||||
cfg.S3Endpoint = os.Getenv("S3_ENDPOINT")
|
||||
if cfg.S3Endpoint == "" {
|
||||
return nil, fmt.Errorf("S3_ENDPOINT is required")
|
||||
}
|
||||
cfg.S3AccessKey = os.Getenv("S3_ACCESS_KEY")
|
||||
if cfg.S3AccessKey == "" {
|
||||
return nil, fmt.Errorf("S3_ACCESS_KEY is required")
|
||||
}
|
||||
cfg.S3SecretKey = os.Getenv("S3_SECRET_KEY")
|
||||
if cfg.S3SecretKey == "" {
|
||||
return nil, fmt.Errorf("S3_SECRET_KEY is required")
|
||||
}
|
||||
cfg.S3Bucket = os.Getenv("S3_BUCKET")
|
||||
if cfg.S3Bucket == "" {
|
||||
// Используем дефолтный бакет если не задан
|
||||
cfg.S3Bucket = "sless-functions"
|
||||
}
|
||||
if v := os.Getenv("S3_USE_SSL"); v == "true" {
|
||||
cfg.S3UseSSL = true
|
||||
}
|
||||
|
||||
// Обязательный адрес registry
|
||||
cfg.RegistryHost = os.Getenv("REGISTRY_HOST")
|
||||
if cfg.RegistryHost == "" {
|
||||
return nil, fmt.Errorf("REGISTRY_HOST is required")
|
||||
}
|
||||
|
||||
// Опциональные параметры с дефолтами
|
||||
if v := os.Getenv("FUNCTION_NAMESPACE_PREFIX"); v != "" {
|
||||
cfg.FunctionNamespacePrefix = v
|
||||
}
|
||||
if v := os.Getenv("BUILDER_IMAGE"); v != "" {
|
||||
cfg.BuilderImage = v
|
||||
}
|
||||
|
||||
// IngressHost — базовый домен для HTTP триггеров
|
||||
cfg.IngressHost = os.Getenv("INGRESS_HOST")
|
||||
if cfg.IngressHost == "" {
|
||||
cfg.IngressHost = "fn.kube5s.ru"
|
||||
}
|
||||
|
||||
// APIToken — обязательный токен для REST API
|
||||
cfg.APIToken = os.Getenv("SLESS_API_TOKEN")
|
||||
if cfg.APIToken == "" {
|
||||
return nil, fmt.Errorf("SLESS_API_TOKEN is required")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Изменено: 2026-03-07
|
||||
// PostgreSQL storage — хранение логов вызовов функций.
|
||||
// Состояние функций (phase, imageRef) хранится в k8s CRD, не здесь.
|
||||
// Здесь только invocations: логи, статусы, время выполнения.
|
||||
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq" // драйвер PostgreSQL
|
||||
)
|
||||
|
||||
// Invocation — запись о вызове функции.
|
||||
type Invocation struct {
|
||||
ID string
|
||||
FunctionName string
|
||||
Namespace string
|
||||
Status string // success, error, timeout
|
||||
DurationMs int32
|
||||
HTTPStatus *int32 // nil для cron триггеров
|
||||
Logs string
|
||||
TriggerType string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Store — клиент для работы с PostgreSQL.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// New открывает подключение к PostgreSQL и проверяет его.
|
||||
func New(dsn string) (*Store, error) {
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open postgres: %w", err)
|
||||
}
|
||||
// Проверяем что подключение реально работает
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
return nil, fmt.Errorf("ping postgres: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(10)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// Close закрывает подключение к БД.
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// SaveInvocation записывает лог вызова функции в БД.
|
||||
func (s *Store) SaveInvocation(ctx context.Context, inv *Invocation) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO invocations (function_name, namespace, status, duration_ms, http_status, logs, trigger_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`, inv.FunctionName, inv.Namespace, inv.Status, inv.DurationMs, inv.HTTPStatus, inv.Logs, inv.TriggerType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save invocation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListInvocations возвращает последние N вызовов для указанной функции.
|
||||
func (s *Store) ListInvocations(ctx context.Context, functionName, namespace string, limit int) ([]*Invocation, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, function_name, namespace, status, duration_ms, http_status, logs, trigger_type, created_at
|
||||
FROM invocations
|
||||
WHERE function_name = $1 AND namespace = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3
|
||||
`, functionName, namespace, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list invocations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []*Invocation
|
||||
for rows.Next() {
|
||||
inv := &Invocation{}
|
||||
if err := rows.Scan(
|
||||
&inv.ID, &inv.FunctionName, &inv.Namespace,
|
||||
&inv.Status, &inv.DurationMs, &inv.HTTPStatus,
|
||||
&inv.Logs, &inv.TriggerType, &inv.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan invocation: %w", err)
|
||||
}
|
||||
result = append(result, inv)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// RunMigrations применяет SQL файлы из директории migrations.
|
||||
// Простая реализация без внешних зависимостей — выполняем один файл.
|
||||
// Используем IF NOT EXISTS в SQL поэтому безопасно запускать повторно.
|
||||
func (s *Store) RunMigrations(ctx context.Context, sql string) error {
|
||||
_, err := s.db.ExecContext(ctx, sql)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run migration: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Изменено: 2026-03-07
|
||||
// S3 storage — загрузка и скачивание zip архивов с кодом функций.
|
||||
// Используем Ceph S3 compatible API (minio-go клиент умеет работать с любым S3).
|
||||
// Код загружается пользователем через REST API, хранится в S3,
|
||||
// builder скачивает его для сборки Docker образа.
|
||||
|
||||
package s3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
// Client — клиент для работы с S3.
|
||||
type Client struct {
|
||||
mc *minio.Client
|
||||
bucket string
|
||||
}
|
||||
|
||||
// New создаёт клиент S3 и проверяет/создаёт бакет.
|
||||
func New(endpoint, accessKey, secretKey, bucket string, useSSL bool) (*Client, error) {
|
||||
mc, err := minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
|
||||
Secure: useSSL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create minio client: %w", err)
|
||||
}
|
||||
return &Client{mc: mc, bucket: bucket}, nil
|
||||
}
|
||||
|
||||
// EnsureBucket создаёт бакет если он не существует.
|
||||
// Вызывается при старте сервиса.
|
||||
func (c *Client) EnsureBucket(ctx context.Context) error {
|
||||
exists, err := c.mc.BucketExists(ctx, c.bucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check bucket: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
if err := c.mc.MakeBucket(ctx, c.bucket, minio.MakeBucketOptions{}); err != nil {
|
||||
return fmt.Errorf("create bucket: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upload загружает zip архив с кодом функции в S3.
|
||||
// Ключ: functions/{namespace}/{name}/{version}.zip
|
||||
// Возвращает ключ объекта для сохранения в CRD.
|
||||
func (c *Client) Upload(ctx context.Context, namespace, funcName, version string, r io.Reader, size int64) (string, error) {
|
||||
key := fmt.Sprintf("functions/%s/%s/%s.zip", namespace, funcName, version)
|
||||
_, err := c.mc.PutObject(ctx, c.bucket, key, r, size, minio.PutObjectOptions{
|
||||
ContentType: "application/zip",
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("upload function code: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Download скачивает zip архив с кодом функции из S3.
|
||||
// Используется builder'ом для сборки образа.
|
||||
func (c *Client) Download(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||
obj, err := c.mc.GetObject(ctx, c.bucket, key, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download function code: %w", err)
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// Delete удаляет архив с кодом функции из S3.
|
||||
// Вызывается при удалении Function CRD.
|
||||
func (c *Client) Delete(ctx context.Context, key string) error {
|
||||
if err := c.mc.RemoveObject(ctx, c.bucket, key, minio.RemoveObjectOptions{}); err != nil {
|
||||
return fmt.Errorf("delete function code: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user