diff --git a/api/v1alpha1/function_types.go b/api/v1alpha1/function_types.go index 4daf5d2..95bc8d4 100644 --- a/api/v1alpha1/function_types.go +++ b/api/v1alpha1/function_types.go @@ -11,8 +11,8 @@ import ( // FunctionSpec — желаемое состояние функции. // Описывает всё необходимое для сборки и запуска пользовательского кода. type FunctionSpec struct { - // Runtime — язык и версия выполнения (go1.21, python3.11, nodejs20) - // +kubebuilder:validation:Enum=go1.21;python3.11;nodejs20 + // Runtime — язык и версия выполнения (go1.23, python3.11, nodejs20) + // +kubebuilder:validation:Enum=go1.23;python3.11;nodejs20 // +kubebuilder:validation:Required Runtime string `json:"runtime"` diff --git a/config/crd/bases/sless.kube5s.ru_functions.yaml b/config/crd/bases/sless.kube5s.ru_functions.yaml index ddd89ea..241c32d 100644 --- a/config/crd/bases/sless.kube5s.ru_functions.yaml +++ b/config/crd/bases/sless.kube5s.ru_functions.yaml @@ -65,10 +65,10 @@ spec: format: int32 type: integer runtime: - description: Runtime — язык и версия выполнения (go1.21, python3.11, + description: Runtime — язык и версия выполнения (go1.23, python3.11, nodejs20) enum: - - go1.21 + - go1.23 - python3.11 - nodejs20 type: string diff --git a/controllers/functionjob_controller.go b/controllers/functionjob_controller.go index 97ced46..acde042 100644 --- a/controllers/functionjob_controller.go +++ b/controllers/functionjob_controller.go @@ -147,11 +147,11 @@ func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) // runner читает SLESS_EVENT и вызывает handle(event) один раз Command: runtimeRunnerCommand(fn.Spec.Runtime), Env: append( - fnEnvVars(fn), - corev1.EnvVar{ + append(fnEnvVars(fn), corev1.EnvVar{ Name: "SLESS_EVENT", Value: eventJSON, - }, + }), + goJobModeEnv(fn.Spec.Runtime)..., ), Resources: corev1.ResourceRequirements{ Limits: corev1.ResourceList{ @@ -217,6 +217,12 @@ func (r *FunctionJobReconciler) syncJobStatus(ctx context.Context, fj *slessv1al // runner читает env SLESS_EVENT и SLESS_ENTRYPOINT, вызывает handle(event) один раз и завершается. func runtimeRunnerCommand(runtime string) []string { switch runtime { + case "go1.23": + // Go runtime: SLESS_MODE=job заставляет server читать SLESS_EVENT и выйти. + // CMD остаётся как в образе (/server), переопределяем через Env. + // Передаём пустую команду — используется CMD из образа (/server). + // SLESS_MODE=job добавляется через Env в fnEnvVars. + return nil // nil = использовать CMD из образа; SLESS_MODE=job в Env case "nodejs20": // inline runner — не требует отдельного файла в образе. // SLESS_ENTRYPOINT="module.func": module=имя файла, func=экспортируемая функция @@ -265,6 +271,15 @@ func fnEnvVars(fn *slessv1alpha1.Function) []corev1.EnvVar { func int32Ptr(i int32) *int32 { return &i } +// goJobModeEnv возвращает SLESS_MODE=job для Go runtime — сигнал /server выполниться разово и выйти. +// Для Python/Node runner задаётся через Command, для Go — через env (CMD /server общий). +func goJobModeEnv(runtime string) []corev1.EnvVar { + if runtime == "go1.23" { + return []corev1.EnvVar{{Name: "SLESS_MODE", Value: "job"}} + } + return nil +} + // getJobPodOutput находит под созданный Job-ом и возвращает его stdout (trimmed). // runner.py/runner.js печатают json.dumps(result) в stdout — это и есть return value функции. // Если под не найден или логи недоступны — возвращает "completed successfully" как fallback. diff --git a/deployments/k8s/operator.yaml b/deployments/k8s/operator.yaml index 0f02fad..77bf758 100644 --- a/deployments/k8s/operator.yaml +++ b/deployments/k8s/operator.yaml @@ -72,7 +72,7 @@ spec: containers: - name: operator # При обновлении версии оператора — менять тег здесь (не latest!) - image: naeel/sless-operator:v0.1.24 + image: naeel/sless-operator:v0.1.25 # Always — чтобы всегда тянуть по точному тегу (не кешировать старый) imagePullPolicy: Always ports: diff --git a/examples/hello-go/code/handler.go b/examples/hello-go/code/handler.go new file mode 100644 index 0000000..a82fb99 --- /dev/null +++ b/examples/hello-go/code/handler.go @@ -0,0 +1,21 @@ +// Создано: 2026-03-11 +// handler.go — пример Go serverless функции. +// Пакет должен называться handler, функция — Handle. +// event содержит тело POST запроса (распарсенный JSON) или метаданные GET (_path, _method, _query). + +package handler + +import "fmt" + +// Handle — точка входа функции. Принимает event как map, возвращает любой тип (сериализуется в JSON). +func Handle(event map[string]interface{}) interface{} { + name, ok := event["name"].(string) + if !ok || name == "" { + name = "world" + } + return map[string]interface{}{ + "message": fmt.Sprintf("Hello, %s! (Go 1.23)", name), + "runtime": "go1.23", + "event": event, + } +} diff --git a/examples/hello-go/http.tf b/examples/hello-go/http.tf new file mode 100644 index 0000000..d24bff0 --- /dev/null +++ b/examples/hello-go/http.tf @@ -0,0 +1,23 @@ +# 2026-03-11 +# http.tf — HTTP-функция на Go: принимает запрос, возвращает приветствие. + +resource "sless_function" "hello_go_http" { + name = "hello-go-http" + runtime = "go1.23" + entrypoint = "handler.Handle" + memory_mb = 128 + timeout_sec = 60 + + source_dir = "${path.module}/code" +} + +resource "sless_trigger" "hello_go_http" { + name = "hello-go-http-trigger" + type = "http" + function = sless_function.hello_go_http.name + enabled = true +} + +output "trigger_url" { + value = sless_trigger.hello_go_http.url +} diff --git a/examples/hello-go/job.tf b/examples/hello-go/job.tf new file mode 100644 index 0000000..9a70769 --- /dev/null +++ b/examples/hello-go/job.tf @@ -0,0 +1,28 @@ +# 2026-03-11 +# job.tf — одноразовый запуск Go функции с event payload. + +resource "sless_function" "hello_go_job" { + name = "hello-go-job" + runtime = "go1.23" + entrypoint = "handler.Handle" + memory_mb = 128 + timeout_sec = 60 + + source_dir = "${path.module}/code" +} + +resource "sless_job" "hello_go_run" { + name = "hello-go-run" + function = sless_function.hello_go_job.name + event_json = jsonencode({ name = "Go" }) + wait_timeout_sec = 300 + run_id = 1 +} + +output "job_phase" { + value = sless_job.hello_go_run.phase +} + +output "job_message" { + value = sless_job.hello_go_run.message +} diff --git a/examples/hello-go/main.tf b/examples/hello-go/main.tf new file mode 100644 index 0000000..0742783 --- /dev/null +++ b/examples/hello-go/main.tf @@ -0,0 +1,17 @@ +# 2026-03-11 +# main.tf — провайдеры для hello-go примера. + +terraform { + required_providers { + sless = { + source = "terra.k8c.ru/naeel/sless" + version = "~> 0.1.13" + } + } +} + +provider "sless" { + endpoint = "https://sless-api.kube5s.ru" + token = var.token + nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1" +} diff --git a/examples/hello-go/variables.tf b/examples/hello-go/variables.tf new file mode 100644 index 0000000..538fc2b --- /dev/null +++ b/examples/hello-go/variables.tf @@ -0,0 +1,10 @@ +# 2026-03-11 +# variables.tf — входные переменные для hello-node примера. + +# JWT токен облака (nubes). Передаётся через terraform.tfvars (gitignored). +# Из токена провайдер вычисляет namespace: sless-{sha256[:8]} +variable "token" { + description = "JWT токен облака для аутентификации в sless API" + type = string + sensitive = true +} diff --git a/internal/builder/context.go b/internal/builder/context.go index 6440dfd..766222a 100644 --- a/internal/builder/context.go +++ b/internal/builder/context.go @@ -35,16 +35,19 @@ func PrepareContext(zipData []byte, runtime string) (*bytes.Buffer, error) { // Это знание принадлежит 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) + dockerfileContent, err := generateDockerfile(runtime, hasRequirements, hasPackageJSON, hasGoMod) if err != nil { // Ошибка здесь означает неподдерживаемый runtime — 400 на уровне handler'а. return nil, err @@ -66,33 +69,60 @@ func runtimeBaseImage(runtime string) (string, error) { return "naeel/sless-runtime-python3.11:v0.1.1", 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)", runtime) + return "", fmt.Errorf("unsupported runtime: %q (supported: python3.11, nodejs20, go1.23)", runtime) } } // generateDockerfile генерирует Dockerfile для kaniko. -// Базовый образ содержит HTTP-обёртку (server.py / server.js). -// Пользовательский код копируется в /app/function/ поверх базового образа. +// Базовый образ содержит HTTP-обёртку (server.py / server.js) или builder (для Go). +// Пользовательский код копируется в /app/function/ (или /app/handler/ для Go) поверх базового образа. // Зависимости устанавливаются ПОСЛЕ COPY — чтобы кеш слоёв работал при повторных сборках. -func generateDockerfile(runtime string, hasRequirements bool, hasPackageJSON bool) ([]byte, error) { +func generateDockerfile(runtime string, hasRequirements bool, hasPackageJSON bool, hasGoMod 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" + 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 (игнорируется). + content := fmt.Sprintf( + "FROM %s AS builder\n"+ + "WORKDIR /app\n"+ + "COPY handler/ /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 } - return []byte(content), nil } // zipToTarGz принимает уже распарсенный *zip.Reader и упаковывает содержимое + Dockerfile в tar.gz. diff --git a/runtimes/go1.23/Dockerfile b/runtimes/go1.23/Dockerfile new file mode 100644 index 0000000..8bc3e0f --- /dev/null +++ b/runtimes/go1.23/Dockerfile @@ -0,0 +1,33 @@ +# Создано: 2026-03-11 +# Base builder image для Go 1.23 serverless функций. +# Это BUILDER-образ (не runtime): содержит Go компилятор + server.go. +# server.go — HTTP-wrapper + job-runner, компилируется ВМЕСТЕ с кодом пользователя. +# +# kaniko делает multi-stage build: +# Stage 1: golang:1.23-alpine → COPY server.go + handler/ → go build → /server +# Stage 2: FROM alpine → COPY /server → минимальный финальный образ. +# +# Почему builder-образ, а не runtime: Go требует статической компиляции — нельзя +# "подключить" пользовательский код динамически как в Python/Node. + +FROM golang:1.23-alpine AS builder + +WORKDIR /app + +# server.go — точка входа (main package), вызывает Handler() из пользовательского кода. +COPY server.go /app/server.go + +# Код пользователя kaniko копирует в /app/handler/ +COPY handler/ /app/handler/ + +# Статическая сборка (без libc) — работает в alpine-контейнере +RUN CGO_ENABLED=0 go build -o /server /app/server.go + +# Финальный минимальный образ +FROM alpine:3.20 + +COPY --from=builder /server /server + +EXPOSE 8080 + +CMD ["/server"] diff --git a/runtimes/go1.23/go.mod b/runtimes/go1.23/go.mod new file mode 100644 index 0000000..9c6f5c5 --- /dev/null +++ b/runtimes/go1.23/go.mod @@ -0,0 +1,3 @@ +module sless/fn + +go 1.23 diff --git a/runtimes/go1.23/server.go b/runtimes/go1.23/server.go new file mode 100644 index 0000000..ba74e8b --- /dev/null +++ b/runtimes/go1.23/server.go @@ -0,0 +1,86 @@ +// Создано: 2026-03-11 +// HTTP-обёртка для serverless функций на Go 1.23. +// Компилируется kaniko ВМЕСТЕ с пользовательским кодом (package handler). +// +// Интерфейс пользователя — файл handler.go: +// +// package handler +// +// func Handle(event map[string]interface{}) interface{} { +// return map[string]interface{}{"hello": event["name"]} +// } +// +// SLESS_MODE=job: разово вызвать Handle(event) → вывести JSON → выйти (для FunctionJob). +// По умолчанию: HTTP-сервер, каждый запрос → Handle(event) → JSON ответ. + +package main + +import ( +"encoding/json" +"fmt" +"io" +"log" +"net/http" +"os" + +"sless/fn/handler" +) + +func main() { +mode := os.Getenv("SLESS_MODE") + +// job-runner: разово вызвать Handle(event), вывести результат в JSON и выйти. +if mode == "job" { +eventJSON := os.Getenv("SLESS_EVENT") +if eventJSON == "" { +eventJSON = "{}" +} +var event map[string]interface{} +if err := json.Unmarshal([]byte(eventJSON), &event); err != nil { +event = map[string]interface{}{} +} +result := handler.Handle(event) +out, _ := json.Marshal(result) +fmt.Println(string(out)) +return +} + +// HTTP-сервер +port := "8080" +http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { +w.Header().Set("Content-Type", "application/json") +fmt.Fprint(w, `{"status":"ok"}`) +}) +http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { +event := map[string]interface{}{} +if r.Method == http.MethodPost || r.Method == http.MethodPut { +body, err := io.ReadAll(r.Body) +if err == nil && len(body) > 0 { +_ = json.Unmarshal(body, &event) +} +} +event["_path"] = r.URL.Path +event["_method"] = r.Method +if q := r.URL.Query(); len(q) > 0 { +qmap := map[string]interface{}{} +for k, v := range q { +if len(v) == 1 { +qmap[k] = v[0] +} else { +qmap[k] = v +} +} +event["_query"] = qmap +} +result := handler.Handle(event) +out, err := json.Marshal(result) +if err != nil { +http.Error(w, `{"error":"marshal failed"}`, 500) +return +} +w.Header().Set("Content-Type", "application/json") +w.Write(out) +}) +log.Printf("sless runtime (go1.23) listening on :%s", port) +log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/terraform/provider/internal/resources/function_resource.go b/terraform/provider/internal/resources/function_resource.go index f8fb358..792b894 100644 --- a/terraform/provider/internal/resources/function_resource.go +++ b/terraform/provider/internal/resources/function_resource.go @@ -97,7 +97,7 @@ func (r *FunctionResource) Schema(_ context.Context, _ resource.SchemaRequest, r stringplanmodifier.RequiresReplace(), }, Validators: []validator.String{ - stringvalidator.OneOf("nodejs20", "python3.11", "go1.21"), + stringvalidator.OneOf("nodejs20", "python3.11", "go1.23"), }, }, "entrypoint": schema.StringAttribute{