refactor: SoC — EnsureNamespace в namespace.go, маршрут /ensure, client.EnsureNamespace, fix secrets в .gitignore

- handler.go: убраны бизнес-логика и k8s-типы (corev1/k8serrors/metav1)
  handler.go теперь только инфраструктура: Handler struct + helpers
- namespace.go: новый файл — EnsureNamespace хендлер живёт здесь
  SoC: создание namespace — отдельная ответственность, не смешивается с CRUD
- router.go: добавлен маршрут POST /v1/namespaces/{namespace}/ensure
- client.go: добавлен метод EnsureNamespace(ctx, ns) → POST /ensure
- provider.go: Configure() вызывает c.EnsureNamespace(ctx, namespace) после создания Client
  Namespace создаётся ОДИН РАЗ при инициализации провайдера
  Resource-хендлеры (Function, Trigger, Job) namespace не трогают
- .gitignore: добавлена директория secrets/ (токены, ключи)
- provider v0.1.13, operator v0.1.21

Operator: naeel/sless-operator:v0.1.21
Provider: terra.k8c.ru/naeel/sless v0.1.13
This commit is contained in:
“Naeel”
2026-03-11 08:37:33 +04:00
parent 5ae2ee7f85
commit a1774e178f
11 changed files with 114 additions and 60 deletions
+2
View File
@@ -2,6 +2,8 @@
# S3 конфиги с кредами — не коммитим # S3 конфиги с кредами — не коммитим
.s3cfg* .s3cfg*
# Секреты — токены, ключи, кредентиалы никогда не коммитим
secrets/
# Binaries for programs and plugins # Binaries for programs and plugins
*.exe *.exe
*.exe~ *.exe~
+1 -1
View File
@@ -11,7 +11,7 @@ terraform {
required_providers { required_providers {
sless = { sless = {
source = "terra.k8c.ru/naeel/sless" source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.12" version = "~> 0.1.13"
} }
} }
} }
-6
View File
@@ -80,12 +80,6 @@ func (h *Handler) ListFunctions(w http.ResponseWriter, r *http.Request) {
// CreateFunction — POST /v1/namespaces/{namespace}/functions // CreateFunction — POST /v1/namespaces/{namespace}/functions
func (h *Handler) CreateFunction(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateFunction(w http.ResponseWriter, r *http.Request) {
ns := namespace(r) ns := namespace(r)
// Гарантируем существование namespace перед созданием Function CRD.
// Подробнее про логику — см. ensureNamespace() в handler.go.
if err := h.ensureNamespace(r.Context(), ns); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("ensure namespace: "+err.Error()))
return
}
var req functionRequest var req functionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
+12 -39
View File
@@ -3,26 +3,28 @@
// Все handlers получают доступ к k8s, S3 и Postgres через эту структуру. // Все handlers получают доступ к k8s, S3 и Postgres через эту структуру.
// Логирование через slog, маршрутизация через gorilla/mux. // Логирование через slog, маршрутизация через gorilla/mux.
// //
// Этот файл — чистая инфраструктура: только Handler struct + вспомогательные функции.
// Бизнес-логика по доменам — в отдельных файлах:
// - namespace.go — EnsureNamespace (создание k8s namespace)
// - functions.go — CRUD функций
// - triggers.go — CRUD триггеров
// - jobs.go — CRUD одноразовых запусков
// - upload.go — загрузка кода, сборка образа
// - invoke.go — прокси вызова функций
//
// Архитектура namespace: // Архитектура namespace:
// - Каждый пользователь работает в своём k8s namespace. // - Namespace создаётся ОДИН РАЗ через EnsureNamespace при инициализации провайдера.
// - Имя namespace вычисляется провайдером из JWT-токена (SHA256 от sub). // - Resource-хендлеры namespace не трогают — это не их ответственность.
// - Оператор сам не создаёт namespace заранее — он делает это при первом // - defaultNamespace используется только как fallback для dev/тестов.
// Create-запросе через ensureNamespace().
// - defaultNamespace используется только как fallback для dev/тестов,
// когда namespace не передан в URL.
package handler package handler
import ( import (
"context"
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
"github.com/gorilla/mux" "github.com/gorilla/mux"
corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client"
@@ -69,32 +71,3 @@ func namespace(r *http.Request) string {
} }
return defaultNamespace return defaultNamespace
} }
// ensureNamespace создаёт k8s namespace если он не существует.
// Вызывается в начале каждого Create-хендлера (CreateFunction, CreateTrigger, CreateJob).
// Идемпотентен: если namespace уже есть — не ошибается.
// Зачем здесь, а не в контроллере: оператор получает запросы ДО того как
// контроллер может что-то создать, поэтому namespace нужен прямо при Create.
func (h *Handler) ensureNamespace(ctx context.Context, ns string) error {
existing := &corev1.Namespace{}
err := h.K8s.Get(ctx, client.ObjectKey{Name: ns}, existing)
if err == nil {
// namespace уже существует — всё ок
return nil
}
if !k8serrors.IsNotFound(err) {
// неожиданная ошибка k8s API
return err
}
// namespace не существует — создаём
ns_obj := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: ns,
// label для идентификации namespace как принадлежащего sless
Labels: map[string]string{
"managed-by": "sless-operator",
},
},
}
return h.K8s.Create(ctx, ns_obj)
}
-5
View File
@@ -64,11 +64,6 @@ func jobToResponse(j *slessv1alpha1.FunctionJob) jobResponse {
// Создаёт FunctionJob CR. Оператор запустит k8s Job асинхронно. // Создаёт FunctionJob CR. Оператор запустит k8s Job асинхронно.
func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) {
ns := namespace(r) ns := namespace(r)
// Гарантируем существование namespace — аналогично CreateFunction.
if err := h.ensureNamespace(r.Context(), ns); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("ensure namespace: "+err.Error()))
return
}
var req jobRequest var req jobRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+57
View File
@@ -0,0 +1,57 @@
// Изменено: 2026-03-11
// namespace.go — хендлер управления namespace пользователя.
// Ответственность: создание k8s namespace (один раз, при инициализации провайдера).
// Вынесен из handler.go намеренно: handler.go — базовый файл без бизнес-логики,
// а работа с corev1/metav1/k8serrors — это бизнес-логика namespace lifecycle.
//
// Точка вызова: провайдер terraform после Configure() вызывает
// POST /v1/namespaces/{namespace}/ensure ОДИН РАЗ перед созданием любых ресурсов.
// Resource-хендлеры (functions, triggers, jobs) namespace НЕ трогают.
package handler
import (
"net/http"
corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// EnsureNamespace — хендлер POST /v1/namespaces/{namespace}/ensure.
// Создаёт k8s namespace если не существует. Идемпотентен: если namespace уже есть —
// возвращает 200 OK вместо 201 Created.
// Вызывается провайдером ОДИН РАЗ в Configure() — до создания любых ресурсов.
func (h *Handler) EnsureNamespace(w http.ResponseWriter, r *http.Request) {
ns := namespace(r)
existing := &corev1.Namespace{}
err := h.K8s.Get(r.Context(), client.ObjectKey{Name: ns}, existing)
if err == nil {
// namespace уже существует
writeJSON(w, http.StatusOK, map[string]string{"namespace": ns, "status": "exists"})
return
}
if !k8serrors.IsNotFound(err) {
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
nsObj := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: ns,
Labels: map[string]string{
"managed-by": "sless-operator",
},
},
}
if err := h.K8s.Create(r.Context(), nsObj); err != nil {
// параллельный запрос уже создал namespace — идемпотентность
if k8serrors.IsAlreadyExists(err) {
writeJSON(w, http.StatusOK, map[string]string{"namespace": ns, "status": "exists"})
return
}
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
writeJSON(w, http.StatusCreated, map[string]string{"namespace": ns, "status": "created"})
}
-5
View File
@@ -74,11 +74,6 @@ func (h *Handler) ListTriggers(w http.ResponseWriter, r *http.Request) {
// CreateTrigger — POST /v1/namespaces/{namespace}/triggers // CreateTrigger — POST /v1/namespaces/{namespace}/triggers
func (h *Handler) CreateTrigger(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateTrigger(w http.ResponseWriter, r *http.Request) {
ns := namespace(r) ns := namespace(r)
// Гарантируем существование namespace — аналогично CreateFunction.
if err := h.ensureNamespace(r.Context(), ns); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("ensure namespace: "+err.Error()))
return
}
var req triggerRequest var req triggerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
-1
View File
@@ -91,4 +91,3 @@ func validateJWT(token string) error {
type jwtError struct{ msg string } type jwtError struct{ msg string }
func (e *jwtError) Error() string { return e.msg } func (e *jwtError) Error() string { return e.msg }
+4
View File
@@ -28,6 +28,10 @@ func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler {
// Суброутер для /v1 — все маршруты API // Суброутер для /v1 — все маршруты API
v1 := r.PathPrefix("/v1").Subrouter() v1 := r.PathPrefix("/v1").Subrouter()
// Namespace lifecycle — вызывается провайдером ОДИН РАЗ при Configure()
// до создания любых ресурсов; идемпотентен.
v1.HandleFunc("/namespaces/{namespace}/ensure", h.EnsureNamespace).Methods(http.MethodPost)
// Functions CRUD // Functions CRUD
v1.HandleFunc("/namespaces/{namespace}/functions", h.ListFunctions).Methods(http.MethodGet) 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", h.CreateFunction).Methods(http.MethodPost)
@@ -496,6 +496,24 @@ func (c *Client) DeleteJob(ctx context.Context, ns, name string) error {
return nil return nil
} }
// EnsureNamespace — POST /v1/namespaces/{ns}/ensure
// Создаёт k8s namespace пользователя если не существует. Идемпотентен.
// Вызывается ОДИН РАЗ из provider.Configure() до любых ресурсных операций.
// 200 OK = namespace уже был, 201 Created = создан сейчас, остальное = ошибка.
func (c *Client) EnsureNamespace(ctx context.Context, ns string) error {
url := fmt.Sprintf("%s/v1/namespaces/%s/ensure", c.endpoint, ns)
resp, err := c.doJSON(ctx, http.MethodPost, url, nil)
if err != nil {
return fmt.Errorf("ensure namespace: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("ensure namespace: status %d: %s", resp.StatusCode, body)
}
return nil
}
// WaitJobDone опрашивает job каждые 5 секунд пока phase не Succeeded или Failed. // WaitJobDone опрашивает job каждые 5 секунд пока phase не Succeeded или Failed.
// Блокирует terraform apply до завершения джоба. // Блокирует terraform apply до завершения джоба.
func (c *Client) WaitJobDone(ctx context.Context, ns, name string, timeout time.Duration) (*JobResponse, error) { func (c *Client) WaitJobDone(ctx context.Context, ns, name string, timeout time.Duration) (*JobResponse, error) {
@@ -10,15 +10,17 @@
// 5. Создаём client.Client с вычисленным namespace. // 5. Создаём client.Client с вычисленным namespace.
// //
// Атрибуты провайдера: // Атрибуты провайдера:
// endpoint — URL sless оператора (env: SLESS_ENDPOINT) //
// token — JWT токен облака, общий для nubes и sless (env: SLESS_API_TOKEN) // endpoint — URL sless оператора (env: SLESS_ENDPOINT)
// nubes_endpoint — URL nubes API для валидации токена (env: NUBES_ENDPOINT, опционально) // token — JWT токен облака, общий для nubes и sless (env: SLESS_API_TOKEN)
// nubes_endpoint — URL nubes API для валидации токена (env: NUBES_ENDPOINT, опционально)
// //
// Env-переменные: SLESS_ENDPOINT, SLESS_API_TOKEN, NUBES_ENDPOINT. // Env-переменные: SLESS_ENDPOINT, SLESS_API_TOKEN, NUBES_ENDPOINT.
package provider package provider
import ( import (
"context" "context"
"fmt"
"os" "os"
"strings" "strings"
@@ -143,6 +145,21 @@ func (p *SlessProvider) Configure(ctx context.Context, req provider.ConfigureReq
// --- 5. Создаём клиент с вычисленным namespace --- // --- 5. Создаём клиент с вычисленным namespace ---
c := client.New(endpoint, token, namespace) c := client.New(endpoint, token, namespace)
// --- 6. Создаём k8s namespace пользователя (идемпотентно) ---
// Namespace создаётся ОДИН РАЗ здесь — resource-хендлеры (Function, Trigger, Job)
// не занимаются созданием namespace; это не их ответственность.
// Если endpoint недоступен в dev-режиме без токена — пропускаем.
if token != "" {
if err := c.EnsureNamespace(ctx, namespace); err != nil {
resp.Diagnostics.AddError(
"Failed to ensure namespace",
fmt.Sprintf("Cannot create namespace %q: %s", namespace, err.Error()),
)
return
}
}
resp.ResourceData = c resp.ResourceData = c
resp.DataSourceData = c resp.DataSourceData = c
} }