From 18f25e7a65ac5eef8eeb8f5cc04cb8a4dbcd3e79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Wed, 11 Mar 2026 09:34:34 +0400 Subject: [PATCH] refactor: Builder SoC + JWKS stub + unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builder SoC (builder/context.go): - Moved generateDockerfile, runtimeBaseImage, zipToTarGz from handler/upload.go to internal/builder/context.go. Reason: knowledge about runtime images and build context structure is a build concern, not an HTTP handler concern. - Added PrepareContext(zipData []byte, runtime string) (*bytes.Buffer, error) — single public entry point. Handler calls one function, gets ready buffer. - zipToTarGz now accepts *zip.Reader instead of []byte to avoid double parsing. - upload.go reduced from ~200 LOC to ~60 LOC (build logic gone). auth.go — JWKS insertion point: - Added verifySignature() stub with detailed comment explaining what v2 implementation needs (JWKS endpoint, kid lookup, RS256/ES256 verify). - Shows exactly where to add the call in validateJWT. Unit tests (9 total, all pass): - controllers: TestBuildDeployment_EnvVarsSorted, TestBuildDeployment_EmptyEnv - handler: TestHopByHopHeaders_* (3 tests) - builder: TestPrepareContext_PythonWithRequirements, _NodeNoPackageJSON, _UnsupportedRuntime, _DockerfileIsFirst --- controllers/function_controller.go | 3 +- controllers/function_controller_unit_test.go | 85 +++++++++++ controllers/trigger_controller.go | 5 +- internal/api/handler/invoke_test.go | 94 ++++++++++++ internal/api/handler/upload.go | 146 ++---------------- internal/api/middleware/auth.go | 22 +++ internal/builder/context.go | 150 ++++++++++++++++++ internal/builder/context_test.go | 153 +++++++++++++++++++ 8 files changed, 521 insertions(+), 137 deletions(-) create mode 100644 controllers/function_controller_unit_test.go create mode 100644 internal/api/handler/invoke_test.go create mode 100644 internal/builder/context.go create mode 100644 internal/builder/context_test.go diff --git a/controllers/function_controller.go b/controllers/function_controller.go index 6529467..4790003 100644 --- a/controllers/function_controller.go +++ b/controllers/function_controller.go @@ -222,7 +222,8 @@ func (r *FunctionReconciler) ensureDeployment(ctx context.Context, fn *slessv1al } return ctrl.Result{}, nil } - // Изменено: 2026-03-11// buildDeployment формирует Deployment манифест для функции. + +// Изменено: 2026-03-11// buildDeployment формирует Deployment манифест для функции. func (r *FunctionReconciler) buildDeployment(fn *slessv1alpha1.Function, namespace string) *appsv1.Deployment { replicas := int32(1) envVars := []corev1.EnvVar{ diff --git a/controllers/function_controller_unit_test.go b/controllers/function_controller_unit_test.go new file mode 100644 index 0000000..cc0a1ab --- /dev/null +++ b/controllers/function_controller_unit_test.go @@ -0,0 +1,85 @@ +// Создано: 2026-03-11 +// Юнит-тесты для FunctionReconciler (без k8s envtest). +// Проверяют логику которую можно тестировать изолированно. + +package controllers + +import ( + "testing" + + slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestBuildDeployment_EnvVarsSorted проверяет что env vars в Deployment всегда +// идут в алфавитном порядке — независимо от порядка в map. +// Важно: нестабильный порядок приводит к лишним pod restarts в k8s. +func TestBuildDeployment_EnvVarsSorted(t *testing.T) { + r := &FunctionReconciler{ + RegistrySecret: "", + } + + fn := &slessv1alpha1.Function{ + ObjectMeta: metav1.ObjectMeta{Name: "test-fn", Namespace: "test-ns"}, + Spec: slessv1alpha1.FunctionSpec{ + Entrypoint: "handler.handle", + MemoryMB: 128, + Env: map[string]string{ + "ZEBRA": "last", + "ALPHA": "first", + "MIDDLE": "middle", + "DATABASE": "url", + }, + }, + Status: slessv1alpha1.FunctionStatus{ + ImageRef: "registry/test:abc123", + }, + } + + dep := r.buildDeployment(fn, "sless-fn-test-ns") + envs := dep.Spec.Template.Spec.Containers[0].Env + + // Первый env всегда SLESS_ENTRYPOINT + if envs[0].Name != "SLESS_ENTRYPOINT" { + t.Fatalf("first env should be SLESS_ENTRYPOINT, got %s", envs[0].Name) + } + + // Остальные — отсортированы по алфавиту + userEnvs := envs[1:] + for i := 1; i < len(userEnvs); i++ { + if userEnvs[i].Name < userEnvs[i-1].Name { + t.Errorf("env vars not sorted at index %d: %s before %s", + i, userEnvs[i-1].Name, userEnvs[i].Name) + } + } + + // Все 4 ключа присутствуют + if len(userEnvs) != 4 { + t.Errorf("expected 4 user env vars, got %d", len(userEnvs)) + } +} + +// TestBuildDeployment_EmptyEnv проверяет что функция без env vars корректно +// создаёт Deployment только с SLESS_ENTRYPOINT. +func TestBuildDeployment_EmptyEnv(t *testing.T) { + r := &FunctionReconciler{} + + fn := &slessv1alpha1.Function{ + ObjectMeta: metav1.ObjectMeta{Name: "bare-fn", Namespace: "ns"}, + Spec: slessv1alpha1.FunctionSpec{ + Entrypoint: "main.run", + MemoryMB: 64, + }, + Status: slessv1alpha1.FunctionStatus{ImageRef: "reg/bare:tag"}, + } + + dep := r.buildDeployment(fn, "sless-fn-ns") + envs := dep.Spec.Template.Spec.Containers[0].Env + + if len(envs) != 1 { + t.Errorf("expected only SLESS_ENTRYPOINT, got %d env vars", len(envs)) + } + if envs[0].Name != "SLESS_ENTRYPOINT" || envs[0].Value != "main.run" { + t.Errorf("unexpected env: %+v", envs[0]) + } +} diff --git a/controllers/trigger_controller.go b/controllers/trigger_controller.go index a73c76c..f637c0a 100644 --- a/controllers/trigger_controller.go +++ b/controllers/trigger_controller.go @@ -219,8 +219,9 @@ func (r *TriggerReconciler) reconcileHTTP(ctx context.Context, tr *slessv1alpha1 // reconcileCron создаёт CronJob который вызывает функцию по HTTP внутри кластера. // curl делает POST на внутренний Service функции — это исключает внешний round-trip. // CronJob размещается в deployNS (sless-fn-{userNS}), НЕ в user-namespace: -// при NetworkPolicy default-deny под в user-ns не может достучаться до Service в sless-fn-ns. -// Размещение CronJob в том же namespace что и Service — гарантирует работу при любой политике. +// +// при NetworkPolicy default-deny под в user-ns не может достучаться до Service в sless-fn-ns. +// Размещение CronJob в том же namespace что и Service — гарантирует работу при любой политике. func (r *TriggerReconciler) reconcileCron(ctx context.Context, tr *slessv1alpha1.Trigger, fn *slessv1alpha1.Function) (ctrl.Result, error) { deployNS := "sless-fn-" + tr.Namespace // Внутренний URL: Service должен быть создан HTTP триггером или заранее diff --git a/internal/api/handler/invoke_test.go b/internal/api/handler/invoke_test.go new file mode 100644 index 0000000..6365428 --- /dev/null +++ b/internal/api/handler/invoke_test.go @@ -0,0 +1,94 @@ +// Создано: 2026-03-11 +// Юнит-тесты для invoke.go — фильтрация hop-by-hop заголовков. +// Не требуют k8s, работают с httptest. + +package handler + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestHopByHopHeaders_FilteredFromResponse проверяет что заголовки управления +// TCP-соединением НЕ пробрасываются клиенту из ответа функции. +// Transfer-Encoding особенно опасен: его пересылка ломает framing тела ответа. +func TestHopByHopHeaders_FilteredFromResponse(t *testing.T) { + // Мок-бэкенд — возвращает hop-by-hop и обычный заголовок + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Custom", "keep-me") + w.Header().Set("Transfer-Encoding", "chunked") // должен быть отфильтрован + w.Header().Set("Connection", "close") // должен быть отфильтрован + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"ok":true}`) + })) + defer backend.Close() + + // Делаем запрос напрямую к бэкенду и применяем нашу логику фильтрации + resp, err := http.Get(backend.URL) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + rec := httptest.NewRecorder() + + // Воспроизводим логику из InvokeFunction + for k, vals := range resp.Header { + if hopByHopHeaders[k] { + continue + } + for _, v := range vals { + rec.Header().Add(k, v) + } + } + + // Обычные заголовки — должны пройти + if rec.Header().Get("Content-Type") == "" { + t.Error("Content-Type should pass through") + } + if rec.Header().Get("X-Custom") == "" { + t.Error("X-Custom should pass through") + } + + // Hop-by-hop — должны быть отфильтрованы + if rec.Header().Get("Transfer-Encoding") != "" { + t.Error("Transfer-Encoding must NOT pass through") + } + if rec.Header().Get("Connection") != "" { + t.Error("Connection must NOT pass through") + } +} + +// TestHopByHopHeaders_MapContainsAllRFC2616 проверяет что карта содержит +// все 8 hop-by-hop заголовков из RFC 2616 §13.5.1. +func TestHopByHopHeaders_MapContainsAllRFC2616(t *testing.T) { + required := []string{ + "Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization", + "Te", "Trailers", "Transfer-Encoding", "Upgrade", + } + for _, h := range required { + if !hopByHopHeaders[h] { + t.Errorf("hopByHopHeaders missing: %s", h) + } + } + + // Content-Type — обычный заголовок, не должен быть в списке + if hopByHopHeaders["Content-Type"] { + t.Error("Content-Type must NOT be in hopByHopHeaders") + } +} + +// TestHopByHopHeaders_CaseCheck проверяет что ключи в карте — с заглавной буквы +// (canonical form которую Go http.Header использует внутри). +func TestHopByHopHeaders_CaseCheck(t *testing.T) { + for k := range hopByHopHeaders { + canonical := http.CanonicalHeaderKey(strings.ToLower(k)) + if k != canonical { + t.Errorf("key %q should be in canonical form %q", k, canonical) + } + } +} diff --git a/internal/api/handler/upload.go b/internal/api/handler/upload.go index 8f77ab9..ad146a5 100644 --- a/internal/api/handler/upload.go +++ b/internal/api/handler/upload.go @@ -1,20 +1,15 @@ -// Изменено: 2026-03-07 +// Изменено: 2026-03-11 // upload.go — обработчик загрузки кода функции. -// Принимает zip от пользователя, генерирует Dockerfile, упаковывает в tar.gz, -// кладёт в S3 и обновляет Function CRD чтобы контроллер запустил kaniko. +// Принимает zip от пользователя, вызывает builder.PrepareContext (Dockerfile + tar.gz), +// кладёт результат в S3 и обновляет Function CRD чтобы контроллер запустил kaniko. // -// Почему tar.gz а не zip: kaniko читает build context только в формате tar (или OCI layout). -// Почему генерируем Dockerfile здесь: пользователь не должен думать про образы — -// это детали платформы, скрытые от него. +// Разделение ответственностей: +// upload.go — HTTP: принять zip, сохранить в S3, обновить CRD. +// builder/context.go — Build: zip+runtime → tar.gz+Dockerfile для kaniko. package handler import ( - "archive/tar" - "archive/zip" - "bytes" - "compress/gzip" - "fmt" "io" "net/http" "time" @@ -23,106 +18,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1" + "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/builder" ) -// runtimeBaseImage возвращает Docker образ базового runtime для данного runtime-идентификатора. -// Соглашение: образы лежат на DockerHub под аккаунтом naeel, тег = версия образа. -// Возвращает ошибку если runtime не поддерживается — это граница валидации. -func runtimeBaseImage(runtime string) (string, error) { - switch runtime { - case "python3.11": - return "naeel/sless-runtime-python3.11:v0.1.1", nil - case "nodejs20": - return "naeel/sless-runtime-nodejs20:v0.1.2", nil - default: - return "", fmt.Errorf("unsupported runtime: %q (supported: python3.11, nodejs20)", runtime) - } -} - -// generateDockerfile генерирует Dockerfile для kaniko. -// Базовый образ содержит HTTP-обёртку (server.py / server.js). -// Пользовательский код копируется в /app/function/ поверх базового образа. -// Зависимости устанавливаются ПОСЛЕ COPY — чтобы кеш слоёв работал при повторных сборках. -func generateDockerfile(runtime string, hasRequirements bool, hasPackageJSON bool) ([]byte, error) { - baseImage, err := runtimeBaseImage(runtime) - if err != nil { - return nil, err - } - content := fmt.Sprintf("FROM %s\nCOPY . /app/function/\n", baseImage) - switch runtime { - case "python3.11": - if hasRequirements { - content += "RUN pip install --no-cache-dir -r /app/function/requirements.txt\n" - } - case "nodejs20": - if hasPackageJSON { - // cd нужен т.к. npm install читает package.json из текущей директории - content += "RUN cd /app/function && npm install --omit=dev\n" - } - } - return []byte(content), nil -} - -// zipToTarGz распаковывает zip и упаковывает содержимое + Dockerfile в tar.gz. -// Результат кладётся в переданный buf. -// Почему распаковываем zip и перепаковываем: kaniko не умеет читать zip-контекст, -// только tar(.gz) или OCI. -func zipToTarGz(zipData []byte, dockerfileContent []byte, buf *bytes.Buffer) error { - zr, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))) - if err != nil { - return fmt.Errorf("parse zip: %w", err) - } - - gw := gzip.NewWriter(buf) - tw := tar.NewWriter(gw) - - // Первым файлом пишем Dockerfile — kaniko ищет его в корне контекста - if err := tw.WriteHeader(&tar.Header{ - Name: "Dockerfile", - Mode: 0644, - Size: int64(len(dockerfileContent)), - ModTime: time.Now(), - }); err != nil { - return fmt.Errorf("write Dockerfile header: %w", err) - } - if _, err := tw.Write(dockerfileContent); err != nil { - return fmt.Errorf("write Dockerfile: %w", err) - } - - // Копируем файлы из zip в tar - for _, f := range zr.File { - if f.FileInfo().IsDir() { - continue // пустые директории не нужны - } - rc, err := f.Open() - if err != nil { - return fmt.Errorf("open zip entry %s: %w", f.Name, err) - } - data, err := io.ReadAll(rc) - rc.Close() - if err != nil { - return fmt.Errorf("read zip entry %s: %w", f.Name, err) - } - - if err := tw.WriteHeader(&tar.Header{ - Name: f.Name, - Mode: 0644, - Size: int64(len(data)), - ModTime: f.Modified, - }); err != nil { - return fmt.Errorf("write tar header %s: %w", f.Name, err) - } - if _, err := tw.Write(data); err != nil { - return fmt.Errorf("write tar entry %s: %w", f.Name, err) - } - } - - if err := tw.Close(); err != nil { - return fmt.Errorf("close tar: %w", err) - } - return gw.Close() -} - // UploadCode — POST /v1/namespaces/{namespace}/functions/{name}/upload // Принимает multipart/form-data с полем "code" (zip архив с кодом функции). // Генерирует Dockerfile, пакует tar.gz, загружает в S3, обновляет Function CRD. @@ -159,37 +57,17 @@ func (h *Handler) UploadCode(w http.ResponseWriter, r *http.Request) { return } - // Сканируем zip на наличие файлов зависимостей для разных runtime - hasRequirements := false // requirements.txt — python3.11 - hasPackageJSON := false // package.json — nodejs20 - if zr, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))); err == nil { - for _, f := range zr.File { - switch f.Name { - case "requirements.txt": - hasRequirements = true - case "package.json": - hasPackageJSON = true - } - } - } - - // Генерируем Dockerfile под runtime функции - dockerfileContent, err := generateDockerfile(fn.Spec.Runtime, hasRequirements, hasPackageJSON) + // Готовим build context: Dockerfile + tar.gz для kaniko. + // Знание о runtime образах и структуре контекста — в builder.PrepareContext, не здесь. + buf, err := builder.PrepareContext(zipData, fn.Spec.Runtime) if err != nil { - writeJSON(w, http.StatusBadRequest, errResp(err.Error())) - return - } - - // Упаковываем Dockerfile + код пользователя в tar.gz для kaniko - var buf bytes.Buffer - if err := zipToTarGz(zipData, dockerfileContent, &buf); err != nil { - writeJSON(w, http.StatusInternalServerError, errResp("pack build context: "+err.Error())) + writeJSON(w, http.StatusBadRequest, errResp("prepare build context: "+err.Error())) return } // Версия на основе timestamp — каждый upload → новый уникальный ключ в S3 version := time.Now().Format("20060102150405") - s3Key, err := h.S3.UploadContext(r.Context(), ns, name, version, &buf, int64(buf.Len())) + s3Key, err := h.S3.UploadContext(r.Context(), ns, name, version, buf, int64(buf.Len())) if err != nil { writeJSON(w, http.StatusInternalServerError, errResp("upload to S3: "+err.Error())) return diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index 0a68fbb..3573da9 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -91,3 +91,25 @@ func validateJWT(token string) error { type jwtError struct{ msg string } func (e *jwtError) Error() string { return e.msg } + +// verifySignature — точка вставки для проверки подписи JWT (v2). +// +// Текущее состояние (v1): подпись НЕ проверяется. +// Причина: публичный ключ nubes недоступен внутри кластера без JWKS endpoint. +// Безопасность обеспечивается "trusted perimeter" — оператор доступен только изнутри кластера. +// +// Когда nubes предоставит JWKS endpoint, реализация: +// +// func verifySignature(token string) error { +// // 1. Получить JWKS: GET {NUBES_JWKS_URL}/.well-known/jwks.json +// // 2. Найти ключ по "kid" из JWT header +// // 3. Проверить подпись RS256/ES256 +// // Пример: github.com/lestrrat-go/jwx/v2/jwk + jwt.Parse +// return nil +// } +// +// После реализации добавить вызов в validateJWT после проверки структуры: +// +// if err := verifySignature(token); err != nil { +// return &jwtError{"signature verification failed: " + err.Error()} +// } diff --git a/internal/builder/context.go b/internal/builder/context.go new file mode 100644 index 0000000..6440dfd --- /dev/null +++ b/internal/builder/context.go @@ -0,0 +1,150 @@ +// Создано: 2026-03-11 +// context.go — подготовка build context для kaniko. +// +// PrepareContext преобразует zip с кодом пользователя в tar.gz с Dockerfile. +// Эта логика живёт в builder/, а НЕ в handler/ — потому что: +// - Знание о runtime образах (какой базовый образ, зависимости) — деталь сборки, не API. +// - handler/upload.go отвечает только за приём HTTP запроса и сохранение результата в S3. +// +// Разделение ответственностей: +// handler/upload.go — HTTP: принять zip, вызвать PrepareContext, загрузить в S3. +// builder/context.go — Build: превратить zip+runtime → tar.gz+Dockerfile для kaniko. + +package builder + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "fmt" + "io" + "time" +) + +// PrepareContext преобразует zip-архив с кодом пользователя в tar.gz build context для kaniko. +// Сканирует zip, определяет нужны ли зависимости, генерирует Dockerfile, упаковывает всё. +// Возвращает готовый буфер для загрузки в S3. +func PrepareContext(zipData []byte, runtime string) (*bytes.Buffer, error) { + zr, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))) + if err != nil { + return nil, fmt.Errorf("parse zip: %w", err) + } + + // Сканируем содержимое zip: наличие файлов зависимостей зависит от runtime. + // Это знание принадлежит builder-у, не HTTP-хендлеру. + hasRequirements := false // requirements.txt — python3.11: pip install + hasPackageJSON := false // package.json — nodejs20: npm install + for _, f := range zr.File { + switch f.Name { + case "requirements.txt": + hasRequirements = true + case "package.json": + hasPackageJSON = true + } + } + + dockerfileContent, err := generateDockerfile(runtime, hasRequirements, hasPackageJSON) + if err != nil { + // Ошибка здесь означает неподдерживаемый runtime — 400 на уровне handler'а. + return nil, err + } + + var buf bytes.Buffer + if err := zipToTarGz(zr, dockerfileContent, &buf); err != nil { + return nil, fmt.Errorf("pack context: %w", err) + } + return &buf, nil +} + +// runtimeBaseImage возвращает Docker образ базового runtime для данного идентификатора. +// Образы на DockerHub под аккаунтом naeel, тег = версия образа. +// Возвращает ошибку если runtime не поддерживается — граница валидации. +func runtimeBaseImage(runtime string) (string, error) { + switch runtime { + case "python3.11": + return "naeel/sless-runtime-python3.11:v0.1.1", nil + case "nodejs20": + return "naeel/sless-runtime-nodejs20:v0.1.2", nil + default: + return "", fmt.Errorf("unsupported runtime: %q (supported: python3.11, nodejs20)", runtime) + } +} + +// generateDockerfile генерирует Dockerfile для kaniko. +// Базовый образ содержит HTTP-обёртку (server.py / server.js). +// Пользовательский код копируется в /app/function/ поверх базового образа. +// Зависимости устанавливаются ПОСЛЕ COPY — чтобы кеш слоёв работал при повторных сборках. +func generateDockerfile(runtime string, hasRequirements bool, hasPackageJSON bool) ([]byte, error) { + baseImage, err := runtimeBaseImage(runtime) + if err != nil { + return nil, err + } + content := fmt.Sprintf("FROM %s\nCOPY . /app/function/\n", baseImage) + switch runtime { + case "python3.11": + if hasRequirements { + content += "RUN pip install --no-cache-dir -r /app/function/requirements.txt\n" + } + case "nodejs20": + if hasPackageJSON { + // cd нужен т.к. npm install читает package.json из текущей директории + content += "RUN cd /app/function && npm install --omit=dev\n" + } + } + return []byte(content), nil +} + +// zipToTarGz принимает уже распарсенный *zip.Reader и упаковывает содержимое + Dockerfile в tar.gz. +// Принимает распарсенный zip чтобы не парсить zip дважды (первый раз уже в PrepareContext). +// kaniko не умеет читать zip-контекст, только tar(.gz) или OCI layout. +func zipToTarGz(zr *zip.Reader, dockerfileContent []byte, buf *bytes.Buffer) error { + gw := gzip.NewWriter(buf) + tw := tar.NewWriter(gw) + + // Первым файлом пишем Dockerfile — kaniko ищет его в корне контекста + if err := tw.WriteHeader(&tar.Header{ + Name: "Dockerfile", + Mode: 0644, + Size: int64(len(dockerfileContent)), + ModTime: time.Now(), + }); err != nil { + return fmt.Errorf("write Dockerfile header: %w", err) + } + if _, err := tw.Write(dockerfileContent); err != nil { + return fmt.Errorf("write Dockerfile: %w", err) + } + + // Копируем файлы из zip в tar + for _, f := range zr.File { + if f.FileInfo().IsDir() { + continue // пустые директории не нужны + } + rc, err := f.Open() + if err != nil { + return fmt.Errorf("open zip entry %s: %w", f.Name, err) + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return fmt.Errorf("read zip entry %s: %w", f.Name, err) + } + + if err := tw.WriteHeader(&tar.Header{ + Name: f.Name, + Mode: 0644, + Size: int64(len(data)), + ModTime: f.Modified, + }); err != nil { + return fmt.Errorf("write tar header %s: %w", f.Name, err) + } + if _, err := tw.Write(data); err != nil { + return fmt.Errorf("write tar entry %s: %w", f.Name, err) + } + } + + if err := tw.Close(); err != nil { + return fmt.Errorf("close tar: %w", err) + } + return gw.Close() +} diff --git a/internal/builder/context_test.go b/internal/builder/context_test.go new file mode 100644 index 0000000..eab4453 --- /dev/null +++ b/internal/builder/context_test.go @@ -0,0 +1,153 @@ +// Создано: 2026-03-11 +// Тесты для builder/context.go — PrepareContext и вспомогательных функций. +// Работают без k8s, только со стандартной библиотекой. + +package builder + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "io" + "strings" + "testing" +) + +// makeTestZip создаёт zip-архив с указанными файлами. +func makeTestZip(t *testing.T, files map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + w := zip.NewWriter(&buf) + for name, content := range files { + f, err := w.Create(name) + if err != nil { + t.Fatalf("create zip entry %s: %v", name, err) + } + if _, err := f.Write([]byte(content)); err != nil { + t.Fatalf("write zip entry %s: %v", name, err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// readTarGz разбирает tar.gz и возвращает map имя→содержимое. +func readTarGz(t *testing.T, data *bytes.Buffer) map[string]string { + t.Helper() + gr, err := gzip.NewReader(data) + if err != nil { + t.Fatalf("gzip reader: %v", err) + } + defer gr.Close() + + result := make(map[string]string) + tr := tar.NewReader(gr) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("tar next: %v", err) + } + b, _ := io.ReadAll(tr) + result[hdr.Name] = string(b) + } + return result +} + +// TestPrepareContext_PythonWithRequirements проверяет что для python3.11 с +// requirements.txt генерируется правильный Dockerfile с pip install. +func TestPrepareContext_PythonWithRequirements(t *testing.T) { + zip := makeTestZip(t, map[string]string{ + "handler.py": "def handle(req): return 'ok'", + "requirements.txt": "flask==3.0.0\n", + }) + + buf, err := PrepareContext(zip, "python3.11") + if err != nil { + t.Fatalf("PrepareContext: %v", err) + } + + entries := readTarGz(t, buf) + + // Dockerfile должен быть + df, ok := entries["Dockerfile"] + if !ok { + t.Fatal("Dockerfile not found in tar") + } + if !strings.Contains(df, "naeel/sless-runtime-python3.11") { + t.Errorf("Dockerfile missing python runtime image: %s", df) + } + if !strings.Contains(df, "pip install") { + t.Errorf("Dockerfile missing pip install: %s", df) + } + + // Исходный файл должен быть в архиве + if _, ok := entries["handler.py"]; !ok { + t.Error("handler.py not found in tar") + } +} + +// TestPrepareContext_NodeNoPackageJSON проверяет что для nodejs20 без package.json +// не добавляется npm install в Dockerfile. +func TestPrepareContext_NodeNoPackageJSON(t *testing.T) { + zip := makeTestZip(t, map[string]string{ + "index.js": "module.exports.handle = () => 'ok'", + }) + + buf, err := PrepareContext(zip, "nodejs20") + if err != nil { + t.Fatalf("PrepareContext: %v", err) + } + + entries := readTarGz(t, buf) + df := entries["Dockerfile"] + + if strings.Contains(df, "npm install") { + t.Error("npm install should NOT appear without package.json") + } + if !strings.Contains(df, "naeel/sless-runtime-nodejs20") { + t.Errorf("Dockerfile missing nodejs runtime: %s", df) + } +} + +// TestPrepareContext_UnsupportedRuntime проверяет что неизвестный runtime +// возвращает ошибку а не паникует. +func TestPrepareContext_UnsupportedRuntime(t *testing.T) { + zip := makeTestZip(t, map[string]string{"main.rb": "puts 'hi'"}) + + _, err := PrepareContext(zip, "ruby3.2") + if err == nil { + t.Fatal("expected error for unsupported runtime") + } + if !strings.Contains(err.Error(), "unsupported runtime") { + t.Errorf("unexpected error message: %v", err) + } +} + +// TestPrepareContext_DockerfileIsFirst проверяет что Dockerfile — первый файл в tar. +// Это требование kaniko: он ищет Dockerfile в корне контекста. +func TestPrepareContext_DockerfileIsFirst(t *testing.T) { + zip := makeTestZip(t, map[string]string{"app.py": "pass"}) + + buf, err := PrepareContext(zip, "python3.11") + if err != nil { + t.Fatal(err) + } + + gr, _ := gzip.NewReader(buf) + defer gr.Close() + tr := tar.NewReader(gr) + + hdr, err := tr.Next() + if err != nil { + t.Fatal(err) + } + if hdr.Name != "Dockerfile" { + t.Errorf("first tar entry should be Dockerfile, got %q", hdr.Name) + } +}