Files

204 lines
9.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Создано: 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()
}