Without ContentLength, Python BaseHTTPRequestHandler read 0 bytes from body. operator v0.1.37, python runtime v0.1.4, pg-table-writer HTML form
183 lines
7.6 KiB
Go
183 lines
7.6 KiB
Go
// Создано: 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.4", nil
|
||
case "nodejs20":
|
||
return "naeel/sless-runtime-nodejs20:v0.1.2", nil
|
||
case "go1.23":
|
||
// Go использует builder-образ (содержит golang:1.23 + server.go).
|
||
// Конечный образ multi-stage — alpine без компилятора.
|
||
return "naeel/sless-runtime-go1.23:v0.1.0", 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.
|
||
// base-образ содержит: server.go (package main) + go.mod (module sless/fn).
|
||
// kaniko копирует пользовательский код в handler/ (package handler).
|
||
// go build видит: /app/server.go + /app/handler/*.go под одним модулем sless/fn.
|
||
// Если у пользователя есть go.mod — он кладётся в /app/handler/go.mod (игнорируется).
|
||
// COPY . /app/handler/ — пользовательские файлы лежат в корне tar-контекста (без prefix).
|
||
// kaniko не умеет переименовывать пути при COPY, поэтому копируем всё "." в handler/.
|
||
content := fmt.Sprintf(
|
||
"FROM %s AS builder\n"+
|
||
"WORKDIR /app\n"+
|
||
"COPY . /app/handler/\n"+
|
||
"RUN CGO_ENABLED=0 go build -o /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"
|
||
}
|
||
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()
|
||
}
|