// Создано: 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 hasGoMod := false // go.mod — go1.23: пользователь может дать свой go.mod for _, f := range zr.File { switch f.Name { case "requirements.txt": hasRequirements = true case "package.json": hasPackageJSON = true case "go.mod": hasGoMod = true } } dockerfileContent, err := generateDockerfile(runtime, hasRequirements, hasPackageJSON, hasGoMod) 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.6", nil case "nodejs20": return "naeel/sless-runtime-nodejs20:v0.1.2", nil case "go1.23": // Go использует builder-образ (содержит golang:1.23 + server/ + go.mod/go.sum с pgx/v5). // v0.1.3: server/ субдиректория + go.work позволяет пользовательский go.mod с любыми зависимостями. return "naeel/sless-runtime-go1.23:v0.1.3", nil default: return "", fmt.Errorf("unsupported runtime: %q (supported: python3.11, nodejs20, go1.23)", runtime) } } // generateDockerfile генерирует Dockerfile для kaniko. // Базовый образ содержит HTTP-обёртку (server.py / server.js) или builder (для Go). // Пользовательский код копируется в /app/function/ (или /app/handler/ для Go) поверх базового образа. // Зависимости устанавливаются ПОСЛЕ COPY — чтобы кеш слоёв работал при повторных сборках. func generateDockerfile(runtime string, hasRequirements bool, hasPackageJSON bool, hasGoMod bool) ([]byte, error) { baseImage, err := runtimeBaseImage(runtime) if err != nil { return nil, err } switch runtime { case "go1.23": // Go: multi-stage build с go.work (v0.1.3+). // base-образ содержит: /app/server/ (module sless/fn/server, package main). // kaniko копирует пользовательский код в /app/handler/. // Модуль handler всегда переименовывается в sless/fn/handler — это позволяет server.go // импортировать его по фиксированному пути без replace-директивы. // Почему нет replace: Go 1.23 запрещает replace для модуля, который одновременно в workspace // (use ./handler). Ошибка: "workspace module sless/fn/handler is replaced at all versions". // Решение: rename module → use workspace → require без replace. goModStep := "" if !hasGoMod { // Пользователь не предоставил go.mod — генерируем минимальный с правильным именем. goModStep = "RUN printf 'module sless/fn/handler\\n\\ngo 1.23\\n' > /app/handler/go.mod\n" } else { // Пользователь предоставил go.mod с произвольным именем модуля — переименовываем. goModStep = "RUN sed -i 's/^module .*/module sless\\/fn\\/handler/' /app/handler/go.mod\n" } content := fmt.Sprintf( "FROM %s AS builder\n"+ "WORKDIR /app\n"+ "COPY . /app/handler/\n"+ goModStep+ // go.work: workspace из двух модулей без replace-директив. "RUN printf 'go 1.23\\n\\nuse ./server\\nuse ./handler\\n' > /app/go.work\n"+ // Патчим server/go.mod в момент kaniko-сборки — добавляем require на handler. // Не меняет базовый образ (выполняется на этапе сборки пользовательского образа). "RUN printf '\\nrequire sless/fn/handler v0.0.0\\n' >> /app/server/go.mod\n"+ "RUN CGO_ENABLED=0 go build -o /server ./server\n"+ "FROM alpine:3.20\n"+ "COPY --from=builder /server /server\n"+ "EXPOSE 8080\n"+ "CMD [\"/server\"]\n", baseImage, ) return []byte(content), nil default: // python3.11, nodejs20 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" } // Проверяем синтаксис всех .py файлов на этапе сборки образа. // Если в коде синтаксическая ошибка — kaniko завершится с ошибкой и фаза = Failed. // Без этого шага образ собирается успешно, а под падает в CrashLoopBackOff при запуске. content += "RUN python -m py_compile $(find /app/function -name '*.py')\n" case "nodejs20": if hasPackageJSON { // cd нужен т.к. npm install читает package.json из текущей директории content += "RUN cd /app/function && npm install --omit=dev\n" } // Проверяем синтаксис всех .js файлов через node --check. content += "RUN find /app/function -name '*.js' | xargs -r node --check\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() }