// Создано: 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() }