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:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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,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":
|
||||||
@@ -93,6 +122,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.
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module sless/fn
|
||||||
|
|
||||||
|
go 1.23
|
||||||
@@ -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{
|
||||||
|
|||||||
Reference in New Issue
Block a user