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
+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()
}