Files
sless/internal/api/handler/upload.go
T
“Naeel” 0268ef0523 fix: multiple fixes + pg-query e2e passed
upload.go:
- Update → Patch (MergeFrom) to avoid resourceVersion conflict when operator
  modifies Function between Get() and Update()

terraform provider v0.1.1:
- trigger_resource.go: trToModel returns StringNull() for empty schedule
  (fixes 'provider produced inconsistent result' for http triggers)
- main.go: bump version to 0.1.1

examples/pg-query:
- handler.py: fix column name started_at → created_at (matches migrations/001)
- main.tf: pin provider to ~> 0.1.1

.gitignore: add terraform state, lock, .terraform/, handler.zip

doc/errors/log.md: documented all 5 errors from this session:
- resourceVersion conflict → use Patch
- terraform inconsistent result for schedule → StringNull
- terraform import not implemented → delete+recreate workaround
- wrong column name → check migrations before writing handlers
- Deployment not restarting after image rebuild → rollout restart / TODO: restartedAt annotation

doc/progress.md: terraform apply e2e 
2026-03-07 10:52:29 +04:00

205 lines
7.9 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:latest", nil
default:
return "", fmt.Errorf("unsupported runtime: %q (supported: python3.11)", runtime)
}
}
// generateDockerfile генерирует Dockerfile для kaniko.
// Базовый образ содержит HTTP-обёртку (server.py).
// Пользовательский код копируется в /app/function/ поверх базового образа.
// Если в zip есть requirements.txt — добавляем pip install (для python runtime).
func generateDockerfile(runtime string, hasRequirements bool) ([]byte, error) {
baseImage, err := runtimeBaseImage(runtime)
if err != nil {
return nil, err
}
content := fmt.Sprintf("FROM %s\nCOPY . /app/function/\n", baseImage)
// pip install только для python runtime и только если requirements.txt есть в zip.
// Делаем это ПОСЛЕ COPY чтобы воспользоваться кешем слоёв Docker при повторных сборках.
if hasRequirements && runtime == "python3.11" {
content += "RUN pip install --no-cache-dir -r /app/function/requirements.txt\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
}
// Проверяем есть ли requirements.txt в zip (для python runtime — pip install)
hasRequirements := false
if zr, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))); err == nil {
for _, f := range zr.File {
if f.Name == "requirements.txt" {
hasRequirements = true
break
}
}
}
// Генерируем Dockerfile под runtime функции
dockerfileContent, err := generateDockerfile(fn.Spec.Runtime, hasRequirements)
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",
})
}