Files
sless/internal/api/handler/upload.go
T
“Naeel” 22c7e92590 feat: add nodejs20 runtime
- runtimes/nodejs20/server.js: HTTP wrapper, exports.handle(event)
- runtimes/nodejs20/Dockerfile: node:20-alpine base image
- naeel/sless-runtime-nodejs20:v0.1.0 pushed to DockerHub
- upload.go: nodejs20 in runtimeBaseImage(), package.json → npm install
- upload.go: python3.11 now uses v0.1.0 tag (no more latest)
- operator v0.1.2 deployed in cluster
- E2E: hello-node-default.fn.kube5s.ru → {"message":"Hello, Naeel! (nodejs20)"}
2026-03-07 17:00:29 +04:00

216 lines
8.2 KiB
Go
Raw 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-07
// upload.go — обработчик загрузки кода функции.
// Принимает zip от пользователя, генерирует Dockerfile, упаковывает в tar.gz,
// кладёт в S3 и обновляет Function CRD чтобы контроллер запустил kaniko.
//
// Почему tar.gz а не zip: kaniko читает build context только в формате tar (или OCI layout).
// Почему генерируем Dockerfile здесь: пользователь не должен думать про образы —
// это детали платформы, скрытые от него.
package handler
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"fmt"
"io"
"net/http"
"time"
"k8s.io/apimachinery/pkg/api/errors"
"sigs.k8s.io/controller-runtime/pkg/client"
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
)
// runtimeBaseImage возвращает Docker образ базового runtime для данного runtime-идентификатора.
// Соглашение: образы лежат на DockerHub под аккаунтом naeel, тег = версия образа.
// Возвращает ошибку если runtime не поддерживается — это граница валидации.
func runtimeBaseImage(runtime string) (string, error) {
switch runtime {
case "python3.11":
return "naeel/sless-runtime-python3.11:v0.1.0", nil
case "nodejs20":
return "naeel/sless-runtime-nodejs20:v0.1.0", 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 и упаковывает содержимое + Dockerfile в tar.gz.
// Результат кладётся в переданный buf.
// Почему распаковываем zip и перепаковываем: kaniko не умеет читать zip-контекст,
// только tar(.gz) или OCI.
func zipToTarGz(zipData []byte, dockerfileContent []byte, buf *bytes.Buffer) error {
zr, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
if err != nil {
return fmt.Errorf("parse zip: %w", err)
}
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()
}
// UploadCode — POST /v1/namespaces/{namespace}/functions/{name}/upload
// Принимает multipart/form-data с полем "code" (zip архив с кодом функции).
// Генерирует Dockerfile, пакует tar.gz, загружает в S3, обновляет Function CRD.
func (h *Handler) UploadCode(w http.ResponseWriter, r *http.Request) {
ns := namespace(r)
name := pathVar(r, "name")
// Проверяем что Function CRD существует и получаем runtime
fn := &slessv1alpha1.Function{}
if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, fn); err != nil {
if errors.IsNotFound(err) {
writeJSON(w, http.StatusNotFound, errResp("function not found"))
return
}
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
// Лимит 32MB на загрузку кода функции
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid multipart form: "+err.Error()))
return
}
file, _, err := r.FormFile("code")
if err != nil {
writeJSON(w, http.StatusBadRequest, errResp(`field "code" is required (zip file)`))
return
}
defer file.Close()
zipData, err := io.ReadAll(file)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("read upload: "+err.Error()))
return
}
// Сканируем zip на наличие файлов зависимостей для разных runtime
hasRequirements := false // requirements.txt — python3.11
hasPackageJSON := false // package.json — nodejs20
if zr, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))); err == nil {
for _, f := range zr.File {
switch f.Name {
case "requirements.txt":
hasRequirements = true
case "package.json":
hasPackageJSON = true
}
}
}
// Генерируем Dockerfile под runtime функции
dockerfileContent, err := generateDockerfile(fn.Spec.Runtime, hasRequirements, hasPackageJSON)
if err != nil {
writeJSON(w, http.StatusBadRequest, errResp(err.Error()))
return
}
// Упаковываем Dockerfile + код пользователя в tar.gz для kaniko
var buf bytes.Buffer
if err := zipToTarGz(zipData, dockerfileContent, &buf); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("pack build context: "+err.Error()))
return
}
// Версия на основе timestamp — каждый upload → новый уникальный ключ в S3
version := time.Now().Format("20060102150405")
s3Key, err := h.S3.UploadContext(r.Context(), ns, name, version, &buf, int64(buf.Len()))
if err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("upload to S3: "+err.Error()))
return
}
// Обновляем Function CRD: новый s3Key и bucket → контроллер запустит kaniko.
// Используем Patch вместо Update чтобы избежать конфликта resourceVersion:
// между Get() выше и Update() контроллер может изменить статус объекта.
// MergePatch обновляет только указанные поля, не требует точного resourceVersion.
patch := client.MergeFrom(fn.DeepCopy())
fn.Spec.S3Key = s3Key
fn.Spec.S3Bucket = h.S3.Bucket()
if err := h.K8s.Patch(r.Context(), fn, patch); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("update function: "+err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{
"s3_key": s3Key,
"phase": string(slessv1alpha1.FunctionPhasePending),
"message": "build queued",
})
}