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:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user