feat: go1.23 runtime support

- runtimes/go1.23/server.go: HTTP-wrapper + job-runner (SLESS_MODE=job)
- runtimes/go1.23/go.mod: module sless/fn (изолирует от корневого go.mod)
- runtimes/go1.23/Dockerfile: multi-stage build (golang:1.23-alpine → alpine:3.20)
- internal/builder/context.go: go1.23 в runtimeBaseImage + generateDockerfile
- controllers/functionjob_controller.go: go1.23 runner (nil cmd + SLESS_MODE=job env)
- api/v1alpha1/function_types.go: enum go1.21 → go1.23
- config/crd/bases/...: CRD обновлён
- terraform/provider: OneOf обновлён
- examples/hello-go: HTTP + job примеры на go1.23
- deployments/k8s/operator.yaml: v0.1.25
This commit is contained in:
“Naeel”
2026-03-11 15:40:47 +04:00
parent babd8e6109
commit a709b38f6b
14 changed files with 290 additions and 24 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ import (
// FunctionSpec — желаемое состояние функции. // FunctionSpec — желаемое состояние функции.
// Описывает всё необходимое для сборки и запуска пользовательского кода. // Описывает всё необходимое для сборки и запуска пользовательского кода.
type FunctionSpec struct { type FunctionSpec struct {
// Runtime — язык и версия выполнения (go1.21, python3.11, nodejs20) // Runtime — язык и версия выполнения (go1.23, python3.11, nodejs20)
// +kubebuilder:validation:Enum=go1.21;python3.11;nodejs20 // +kubebuilder:validation:Enum=go1.23;python3.11;nodejs20
// +kubebuilder:validation:Required // +kubebuilder:validation:Required
Runtime string `json:"runtime"` Runtime string `json:"runtime"`
@@ -65,10 +65,10 @@ spec:
format: int32 format: int32
type: integer type: integer
runtime: runtime:
description: Runtime — язык и версия выполнения (go1.21, python3.11, description: Runtime — язык и версия выполнения (go1.23, python3.11,
nodejs20) nodejs20)
enum: enum:
- go1.21 - go1.23
- python3.11 - python3.11
- nodejs20 - nodejs20
type: string type: string
+18 -3
View File
@@ -147,11 +147,11 @@ func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request)
// runner читает SLESS_EVENT и вызывает handle(event) один раз // runner читает SLESS_EVENT и вызывает handle(event) один раз
Command: runtimeRunnerCommand(fn.Spec.Runtime), Command: runtimeRunnerCommand(fn.Spec.Runtime),
Env: append( Env: append(
fnEnvVars(fn), append(fnEnvVars(fn), corev1.EnvVar{
corev1.EnvVar{
Name: "SLESS_EVENT", Name: "SLESS_EVENT",
Value: eventJSON, Value: eventJSON,
}, }),
goJobModeEnv(fn.Spec.Runtime)...,
), ),
Resources: corev1.ResourceRequirements{ Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{ Limits: corev1.ResourceList{
@@ -217,6 +217,12 @@ func (r *FunctionJobReconciler) syncJobStatus(ctx context.Context, fj *slessv1al
// runner читает env SLESS_EVENT и SLESS_ENTRYPOINT, вызывает handle(event) один раз и завершается. // runner читает env SLESS_EVENT и SLESS_ENTRYPOINT, вызывает handle(event) один раз и завершается.
func runtimeRunnerCommand(runtime string) []string { func runtimeRunnerCommand(runtime string) []string {
switch runtime { 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": case "nodejs20":
// inline runner — не требует отдельного файла в образе. // inline runner — не требует отдельного файла в образе.
// SLESS_ENTRYPOINT="module.func": module=имя файла, func=экспортируемая функция // SLESS_ENTRYPOINT="module.func": module=имя файла, func=экспортируемая функция
@@ -265,6 +271,15 @@ func fnEnvVars(fn *slessv1alpha1.Function) []corev1.EnvVar {
func int32Ptr(i int32) *int32 { return &i } 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). // getJobPodOutput находит под созданный Job-ом и возвращает его stdout (trimmed).
// runner.py/runner.js печатают json.dumps(result) в stdout — это и есть return value функции. // runner.py/runner.js печатают json.dumps(result) в stdout — это и есть return value функции.
// Если под не найден или логи недоступны — возвращает "completed successfully" как fallback. // Если под не найден или логи недоступны — возвращает "completed successfully" как fallback.
+1 -1
View File
@@ -72,7 +72,7 @@ spec:
containers: containers:
- name: operator - name: operator
# При обновлении версии оператора — менять тег здесь (не latest!) # При обновлении версии оператора — менять тег здесь (не latest!)
image: naeel/sless-operator:v0.1.24 image: naeel/sless-operator:v0.1.25
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый) # Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
+21
View File
@@ -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,
}
}
+23
View File
@@ -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
}
+28
View File
@@ -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
}
+17
View File
@@ -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"
}
+10
View File
@@ -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
}
+35 -5
View File
@@ -35,16 +35,19 @@ func PrepareContext(zipData []byte, runtime string) (*bytes.Buffer, error) {
// Это знание принадлежит builder-у, не HTTP-хендлеру. // Это знание принадлежит builder-у, не HTTP-хендлеру.
hasRequirements := false // requirements.txt — python3.11: pip install hasRequirements := false // requirements.txt — python3.11: pip install
hasPackageJSON := false // package.json — nodejs20: npm install hasPackageJSON := false // package.json — nodejs20: npm install
hasGoMod := false // go.mod — go1.23: пользователь может дать свой go.mod
for _, f := range zr.File { for _, f := range zr.File {
switch f.Name { switch f.Name {
case "requirements.txt": case "requirements.txt":
hasRequirements = true hasRequirements = true
case "package.json": case "package.json":
hasPackageJSON = true hasPackageJSON = true
case "go.mod":
hasGoMod = true
} }
} }
dockerfileContent, err := generateDockerfile(runtime, hasRequirements, hasPackageJSON) dockerfileContent, err := generateDockerfile(runtime, hasRequirements, hasPackageJSON, hasGoMod)
if err != nil { if err != nil {
// Ошибка здесь означает неподдерживаемый runtime — 400 на уровне handler'а. // Ошибка здесь означает неподдерживаемый runtime — 400 на уровне handler'а.
return nil, err return nil, err
@@ -66,20 +69,46 @@ func runtimeBaseImage(runtime string) (string, error) {
return "naeel/sless-runtime-python3.11:v0.1.1", nil return "naeel/sless-runtime-python3.11:v0.1.1", nil
case "nodejs20": case "nodejs20":
return "naeel/sless-runtime-nodejs20:v0.1.2", nil 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: 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. // generateDockerfile генерирует Dockerfile для kaniko.
// Базовый образ содержит HTTP-обёртку (server.py / server.js). // Базовый образ содержит HTTP-обёртку (server.py / server.js) или builder (для Go).
// Пользовательский код копируется в /app/function/ поверх базового образа. // Пользовательский код копируется в /app/function/ (или /app/handler/ для Go) поверх базового образа.
// Зависимости устанавливаются ПОСЛЕ COPY — чтобы кеш слоёв работал при повторных сборках. // Зависимости устанавливаются ПОСЛЕ 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) baseImage, err := runtimeBaseImage(runtime)
if err != nil { if err != nil {
return nil, err 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 (игнорируется).
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) content := fmt.Sprintf("FROM %s\nCOPY . /app/function/\n", baseImage)
switch runtime { switch runtime {
case "python3.11": case "python3.11":
@@ -94,6 +123,7 @@ func generateDockerfile(runtime string, hasRequirements bool, hasPackageJSON boo
} }
return []byte(content), nil return []byte(content), nil
} }
}
// zipToTarGz принимает уже распарсенный *zip.Reader и упаковывает содержимое + Dockerfile в tar.gz. // zipToTarGz принимает уже распарсенный *zip.Reader и упаковывает содержимое + Dockerfile в tar.gz.
// Принимает распарсенный zip чтобы не парсить zip дважды (первый раз уже в PrepareContext). // Принимает распарсенный zip чтобы не парсить zip дважды (первый раз уже в PrepareContext).
+33
View File
@@ -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"]
+3
View File
@@ -0,0 +1,3 @@
module sless/fn
go 1.23
+86
View File
@@ -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))
}
@@ -97,7 +97,7 @@ func (r *FunctionResource) Schema(_ context.Context, _ resource.SchemaRequest, r
stringplanmodifier.RequiresReplace(), stringplanmodifier.RequiresReplace(),
}, },
Validators: []validator.String{ Validators: []validator.String{
stringvalidator.OneOf("nodejs20", "python3.11", "go1.21"), stringvalidator.OneOf("nodejs20", "python3.11", "go1.23"),
}, },
}, },
"entrypoint": schema.StringAttribute{ "entrypoint": schema.StringAttribute{