refactor: Builder SoC + JWKS stub + unit tests

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
This commit is contained in:
“Naeel”
2026-03-11 09:34:34 +04:00
parent e761439546
commit 18f25e7a65
8 changed files with 521 additions and 137 deletions
+2 -1
View File
@@ -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{
@@ -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])
}
}
+3 -2
View File
@@ -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 триггером или заранее
+94
View File
@@ -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)
}
}
}
+12 -134
View File
@@ -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
+22
View File
@@ -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()}
// }
+150
View File
@@ -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()
}
+153
View File
@@ -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)
}
}