From a1774e178f4d0181570e92a660164a433e5a86d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Wed, 11 Mar 2026 08:37:33 +0400 Subject: [PATCH] =?UTF-8?q?refactor:=20SoC=20=E2=80=94=20EnsureNamespace?= =?UTF-8?q?=20=D0=B2=20namespace.go,=20=D0=BC=D0=B0=D1=80=D1=88=D1=80?= =?UTF-8?q?=D1=83=D1=82=20/ensure,=20client.EnsureNamespace,=20fix=20secre?= =?UTF-8?q?ts=20=D0=B2=20.gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 2 + examples/hello-node/main.tf | 2 +- internal/api/handler/functions.go | 6 -- internal/api/handler/handler.go | 51 ++++------------- internal/api/handler/jobs.go | 5 -- internal/api/handler/namespace.go | 57 +++++++++++++++++++ internal/api/handler/triggers.go | 5 -- internal/api/middleware/auth.go | 1 - internal/api/router.go | 4 ++ terraform/provider/internal/client/client.go | 18 ++++++ .../provider/internal/provider/provider.go | 23 +++++++- 11 files changed, 114 insertions(+), 60 deletions(-) create mode 100644 internal/api/handler/namespace.go diff --git a/.gitignore b/.gitignore index 9f9ec05..5ab0040 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ # S3 конфиги с кредами — не коммитим .s3cfg* +# Секреты — токены, ключи, кредентиалы никогда не коммитим +secrets/ # Binaries for programs and plugins *.exe *.exe~ diff --git a/examples/hello-node/main.tf b/examples/hello-node/main.tf index 613856c..e3584dd 100644 --- a/examples/hello-node/main.tf +++ b/examples/hello-node/main.tf @@ -11,7 +11,7 @@ terraform { required_providers { sless = { source = "terra.k8c.ru/naeel/sless" - version = "~> 0.1.12" + version = "~> 0.1.13" } } } diff --git a/internal/api/handler/functions.go b/internal/api/handler/functions.go index 391d172..d7c4bee 100644 --- a/internal/api/handler/functions.go +++ b/internal/api/handler/functions.go @@ -80,12 +80,6 @@ func (h *Handler) ListFunctions(w http.ResponseWriter, r *http.Request) { // CreateFunction — POST /v1/namespaces/{namespace}/functions func (h *Handler) CreateFunction(w http.ResponseWriter, r *http.Request) { 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 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) diff --git a/internal/api/handler/handler.go b/internal/api/handler/handler.go index 2f54ebd..6bd3fec 100644 --- a/internal/api/handler/handler.go +++ b/internal/api/handler/handler.go @@ -3,26 +3,28 @@ // Все handlers получают доступ к k8s, S3 и Postgres через эту структуру. // Логирование через slog, маршрутизация через gorilla/mux. // +// Этот файл — чистая инфраструктура: только Handler struct + вспомогательные функции. +// Бизнес-логика по доменам — в отдельных файлах: +// - namespace.go — EnsureNamespace (создание k8s namespace) +// - functions.go — CRUD функций +// - triggers.go — CRUD триггеров +// - jobs.go — CRUD одноразовых запусков +// - upload.go — загрузка кода, сборка образа +// - invoke.go — прокси вызова функций +// // Архитектура namespace: -// - Каждый пользователь работает в своём k8s namespace. -// - Имя namespace вычисляется провайдером из JWT-токена (SHA256 от sub). -// - Оператор сам не создаёт namespace заранее — он делает это при первом -// Create-запросе через ensureNamespace(). -// - defaultNamespace используется только как fallback для dev/тестов, -// когда namespace не передан в URL. +// - Namespace создаётся ОДИН РАЗ через EnsureNamespace при инициализации провайдера. +// - Resource-хендлеры namespace не трогают — это не их ответственность. +// - defaultNamespace используется только как fallback для dev/тестов. package handler import ( - "context" "encoding/json" "log/slog" "net/http" "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" "sigs.k8s.io/controller-runtime/pkg/client" @@ -69,32 +71,3 @@ func namespace(r *http.Request) string { } 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) -} diff --git a/internal/api/handler/jobs.go b/internal/api/handler/jobs.go index 114cd24..30010c3 100644 --- a/internal/api/handler/jobs.go +++ b/internal/api/handler/jobs.go @@ -64,11 +64,6 @@ func jobToResponse(j *slessv1alpha1.FunctionJob) jobResponse { // Создаёт FunctionJob CR. Оператор запустит k8s Job асинхронно. func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) { 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 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { diff --git a/internal/api/handler/namespace.go b/internal/api/handler/namespace.go new file mode 100644 index 0000000..0699ed1 --- /dev/null +++ b/internal/api/handler/namespace.go @@ -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"}) +} diff --git a/internal/api/handler/triggers.go b/internal/api/handler/triggers.go index f3afbd9..b772d54 100644 --- a/internal/api/handler/triggers.go +++ b/internal/api/handler/triggers.go @@ -74,11 +74,6 @@ func (h *Handler) ListTriggers(w http.ResponseWriter, r *http.Request) { // CreateTrigger — POST /v1/namespaces/{namespace}/triggers func (h *Handler) CreateTrigger(w http.ResponseWriter, r *http.Request) { 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 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index 97853ff..0a68fbb 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -91,4 +91,3 @@ func validateJWT(token string) error { type jwtError struct{ msg string } func (e *jwtError) Error() string { return e.msg } - diff --git a/internal/api/router.go b/internal/api/router.go index 042fa59..7b43251 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -28,6 +28,10 @@ func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler { // Суброутер для /v1 — все маршруты API v1 := r.PathPrefix("/v1").Subrouter() + // Namespace lifecycle — вызывается провайдером ОДИН РАЗ при Configure() + // до создания любых ресурсов; идемпотентен. + v1.HandleFunc("/namespaces/{namespace}/ensure", h.EnsureNamespace).Methods(http.MethodPost) + // Functions CRUD v1.HandleFunc("/namespaces/{namespace}/functions", h.ListFunctions).Methods(http.MethodGet) v1.HandleFunc("/namespaces/{namespace}/functions", h.CreateFunction).Methods(http.MethodPost) diff --git a/terraform/provider/internal/client/client.go b/terraform/provider/internal/client/client.go index dce52c1..5a864da 100644 --- a/terraform/provider/internal/client/client.go +++ b/terraform/provider/internal/client/client.go @@ -496,6 +496,24 @@ func (c *Client) DeleteJob(ctx context.Context, ns, name string) error { 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. // Блокирует terraform apply до завершения джоба. func (c *Client) WaitJobDone(ctx context.Context, ns, name string, timeout time.Duration) (*JobResponse, error) { diff --git a/terraform/provider/internal/provider/provider.go b/terraform/provider/internal/provider/provider.go index c6cd2a0..1238903 100644 --- a/terraform/provider/internal/provider/provider.go +++ b/terraform/provider/internal/provider/provider.go @@ -10,15 +10,17 @@ // 5. Создаём client.Client с вычисленным namespace. // // Атрибуты провайдера: -// endpoint — URL sless оператора (env: SLESS_ENDPOINT) -// token — JWT токен облака, общий для nubes и sless (env: SLESS_API_TOKEN) -// nubes_endpoint — URL nubes API для валидации токена (env: NUBES_ENDPOINT, опционально) +// +// endpoint — URL sless оператора (env: SLESS_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. package provider import ( "context" + "fmt" "os" "strings" @@ -143,6 +145,21 @@ func (p *SlessProvider) Configure(ctx context.Context, req provider.ConfigureReq // --- 5. Создаём клиент с вычисленным 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.DataSourceData = c }