From c61e822308d5a9e82dc8beb7044122e40056c420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 7 Mar 2026 09:32:59 +0400 Subject: [PATCH] feat: python3.11 base runtime image + DockerHub registry support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runtimes/python3.11/: Dockerfile + server.py (HTTP wrapper на :8080) - builder.go: RegistrySecret field, volume mount docker-creds для kaniko, убран --skip-tls-verify - config.go: добавлен RegistrySecret (env REGISTRY_SECRET, default sless-registry-auth) - main.go: передаём RegistrySecret в builder.Config - hack/create-registry-secret.sh: создаёт k8s Secret из DockerHub токена Образ наружу: naeel/sless-runtime-python3.11:latest (DockerHub) K8s secret: sless-registry-auth в namespace sless --- hack/create-registry-secret.sh | 32 +++++++++++++ internal/builder/builder.go | 86 ++++++++++++++++++++++++---------- internal/config/config.go | 13 ++++- main.go | 15 +++--- runtimes/python3.11/Dockerfile | 15 ++++++ runtimes/python3.11/server.py | 67 ++++++++++++++++++++++++++ 6 files changed, 195 insertions(+), 33 deletions(-) create mode 100755 hack/create-registry-secret.sh create mode 100644 runtimes/python3.11/Dockerfile create mode 100644 runtimes/python3.11/server.py diff --git a/hack/create-registry-secret.sh b/hack/create-registry-secret.sh new file mode 100755 index 0000000..22af144 --- /dev/null +++ b/hack/create-registry-secret.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Изменено: 2026-03-07 +# Создаёт k8s Secret sless-registry-auth с DockerHub кредами для kaniko. +# Kaniko монтирует этот secret как /kaniko/.docker/config.json для push образов. +# +# Использование: +# DOCKER_HUB_USER=naeel DOCKER_HUB_TOKEN= ./hack/create-registry-secret.sh +# +# Требуется: kubectl, авторизованный context с доступом к namespace sless. + +set -euo pipefail + +NAMESPACE="sless" +SECRET_NAME="sless-registry-auth" + +if [[ -z "${DOCKER_HUB_USER:-}" ]]; then + echo "ERROR: DOCKER_HUB_USER env var is required" + exit 1 +fi +if [[ -z "${DOCKER_HUB_TOKEN:-}" ]]; then + echo "ERROR: DOCKER_HUB_TOKEN env var is required (DockerHub access token, not password)" + exit 1 +fi + +kubectl create secret docker-registry "${SECRET_NAME}" \ + --docker-username="${DOCKER_HUB_USER}" \ + --docker-password="${DOCKER_HUB_TOKEN}" \ + --docker-server="https://index.docker.io/v1/" \ + --namespace="${NAMESPACE}" \ + --dry-run=client -o yaml | kubectl apply -f - + +echo "OK: secret '${SECRET_NAME}' applied in namespace '${NAMESPACE}'" diff --git a/internal/builder/builder.go b/internal/builder/builder.go index d897d43..004b5b6 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -21,38 +21,41 @@ import ( // Builder — управляет сборкой Docker образов через kaniko Jobs в k8s. type Builder struct { - client client.Client - builderImage string // образ kaniko - registryHost string // куда пушим образ - s3Endpoint string // откуда kaniko берёт код - s3AccessKey string - s3SecretKey string - s3Bucket string - namespace string // namespace где запускаем build Job'ы + client client.Client + builderImage string // образ kaniko + registryHost string // куда пушим образ + registrySecret string // имя k8s Secret с docker-кредами для kaniko + s3Endpoint string // откуда kaniko берёт код + s3AccessKey string + s3SecretKey string + s3Bucket string + namespace string // namespace где запускаем build Job'ы } // Config — параметры для создания Builder'а. type Config struct { - BuilderImage string - RegistryHost string - S3Endpoint string - S3AccessKey string - S3SecretKey string - S3Bucket string - Namespace string + BuilderImage string + RegistryHost string + RegistrySecret string // имя k8s Secret с .dockerconfigjson для пуша образов + S3Endpoint string + S3AccessKey string + S3SecretKey string + S3Bucket string + Namespace string } // New создаёт новый Builder. func New(c client.Client, cfg Config) *Builder { return &Builder{ - client: c, - builderImage: cfg.BuilderImage, - registryHost: cfg.RegistryHost, - s3Endpoint: cfg.S3Endpoint, - s3AccessKey: cfg.S3AccessKey, - s3SecretKey: cfg.S3SecretKey, - s3Bucket: cfg.S3Bucket, - namespace: cfg.Namespace, + client: c, + builderImage: cfg.BuilderImage, + registryHost: cfg.RegistryHost, + registrySecret: cfg.RegistrySecret, + s3Endpoint: cfg.S3Endpoint, + s3AccessKey: cfg.S3AccessKey, + s3SecretKey: cfg.S3SecretKey, + s3Bucket: cfg.S3Bucket, + namespace: cfg.Namespace, } } @@ -96,8 +99,7 @@ func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string) Args: []string{ "--context=" + s3ContextURL, "--destination=" + imageRef, - "--skip-tls-verify", // для внутреннего registry без TLS - "--cache=true", // кешируем слои для ускорения повторных сборок + "--cache=true", // кешируем слои для ускорения повторных сборок }, Env: []corev1.EnvVar{ {Name: "AWS_ACCESS_KEY_ID", Value: b.s3AccessKey}, @@ -106,8 +108,11 @@ func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string) // Kaniko использует AWS SDK совместимый с S3 — задаём кастомный endpoint {Name: "AWS_REGION", Value: "us-east-1"}, }, + // Монтируем docker-креды из k8s Secret — kaniko читает авторизацию из /kaniko/.docker/config.json + VolumeMounts: b.dockerConfigMounts(), }, }, + Volumes: b.dockerConfigVolumes(), }, }, }, @@ -152,4 +157,35 @@ func (b *Builder) Cleanup(ctx context.Context, jobName string) error { return b.client.Delete(ctx, job, &client.DeleteOptions{PropagationPolicy: &propagation}) } +// dockerConfigVolumes возвращает Volume с docker-кредами если RegistrySecret задан. +// Ключ .dockerconfigjson монтируется как config.json — стандартное имя для kaniko. +func (b *Builder) dockerConfigVolumes() []corev1.Volume { + if b.registrySecret == "" { + return nil + } + return []corev1.Volume{ + { + Name: "docker-config", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: b.registrySecret, + Items: []corev1.KeyToPath{ + {Key: ".dockerconfigjson", Path: "config.json"}, + }, + }, + }, + }, + } +} + +// dockerConfigMounts возвращает VolumeMount для docker-кредов если RegistrySecret задан. +func (b *Builder) dockerConfigMounts() []corev1.VolumeMount { + if b.registrySecret == "" { + return nil + } + return []corev1.VolumeMount{ + {Name: "docker-config", MountPath: "/kaniko/.docker"}, + } +} + func int32Ptr(i int32) *int32 { return &i } diff --git a/internal/config/config.go b/internal/config/config.go index 3529ebb..a3e634b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,9 +26,14 @@ type Config struct { S3Bucket string S3UseSSL bool - // Registry — адрес внутреннего Docker registry куда пушим собранные образы + // Registry — куда пушим собранные образы функций (DockerHub: "naeel", приватный: "host:port/prefix") RegistryHost string + // RegistrySecret — имя k8s Secret типа kubernetes.io/dockerconfigjson + // Монтируется в kaniko Job для авторизации в registry. + // Создаётся через hack/create-registry-secret.sh + RegistrySecret string + // FunctionNamespace — префикс namespace для функций пользователей. // Итоговый namespace: FunctionNamespacePrefix + "-" + functionName FunctionNamespacePrefix string @@ -98,6 +103,12 @@ func Load() (*Config, error) { return nil, fmt.Errorf("REGISTRY_HOST is required") } + // Secret с docker-кредами для kaniko (опционально — без него push в приватный registry упадёт) + cfg.RegistrySecret = os.Getenv("REGISTRY_SECRET") + if cfg.RegistrySecret == "" { + cfg.RegistrySecret = "sless-registry-auth" + } + // Опциональные параметры с дефолтами if v := os.Getenv("FUNCTION_NAMESPACE_PREFIX"); v != "" { cfg.FunctionNamespacePrefix = v diff --git a/main.go b/main.go index 0a6549c..047ac35 100644 --- a/main.go +++ b/main.go @@ -118,13 +118,14 @@ func main() { // Builder использует k8s client из manager'а bldr := builder.New(mgr.GetClient(), builder.Config{ - BuilderImage: cfg.BuilderImage, - RegistryHost: cfg.RegistryHost, - S3Endpoint: cfg.S3Endpoint, - S3AccessKey: cfg.S3AccessKey, - S3SecretKey: cfg.S3SecretKey, - S3Bucket: cfg.S3Bucket, - Namespace: "sless", + BuilderImage: cfg.BuilderImage, + RegistryHost: cfg.RegistryHost, + RegistrySecret: cfg.RegistrySecret, + S3Endpoint: cfg.S3Endpoint, + S3AccessKey: cfg.S3AccessKey, + S3SecretKey: cfg.S3SecretKey, + S3Bucket: cfg.S3Bucket, + Namespace: "sless", }) if err = (&controllers.FunctionReconciler{ diff --git a/runtimes/python3.11/Dockerfile b/runtimes/python3.11/Dockerfile new file mode 100644 index 0000000..db6b07f --- /dev/null +++ b/runtimes/python3.11/Dockerfile @@ -0,0 +1,15 @@ +# Изменено: 2026-03-07 +# Base runtime image для Python 3.11 serverless функций. +# Содержит только HTTP-обёртку (server.py). +# Пользовательский код докапывается kaniko поверх этого образа: COPY . /app/function/ +# Почему slim: минимальный размер образа, стандартная библиотека Python не нужна доп. пакетов. + +FROM python:3.11-slim + +WORKDIR /app + +COPY server.py /app/server.py + +EXPOSE 8080 + +CMD ["python", "/app/server.py"] diff --git a/runtimes/python3.11/server.py b/runtimes/python3.11/server.py new file mode 100644 index 0000000..74bb563 --- /dev/null +++ b/runtimes/python3.11/server.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +# Изменено: 2026-03-07 +# HTTP-обёртка для serverless функций на Python 3.11. +# Загружает handler.py из /app/function/ и вызывает handle(event) на каждый запрос. +# Почему importlib: нужно загружать модуль из фиксированного пути вне sys.path. + +import sys +import json +import importlib.util +from http.server import HTTPServer, BaseHTTPRequestHandler + +HANDLER_PATH = "/app/function/handler.py" +PORT = 8080 + + +def load_handler(): + # Загружаем модуль пользователя динамически — путь известен только в runtime. + spec = importlib.util.spec_from_file_location("handler", HANDLER_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + if not hasattr(module, "handle"): + raise AttributeError(f"handler.py must define a 'handle(event)' function") + return module.handle + + +_handle = load_handler() + + +class FunctionHandler(BaseHTTPRequestHandler): + def log_message(self, format, *args): + # Логи в stderr чтобы не мешать stdout + sys.stderr.write("%s - [%s] %s\n" % ( + self.address_string(), + self.log_date_time_string(), + format % args, + )) + + def do_GET(self): + if self.path == "/health": + self._respond(200, {"status": "ok"}) + else: + self._respond(200, _handle({})) + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + try: + event = json.loads(body) if body else {} + except json.JSONDecodeError: + # Если тело не JSON — передаём как строку, не ломаем вызов + event = {"body": body.decode("utf-8", errors="replace")} + result = _handle(event) + self._respond(200, result) + + def _respond(self, status, data): + body = json.dumps(data).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +if __name__ == "__main__": + server = HTTPServer(("0.0.0.0", PORT), FunctionHandler) + print(f"sless runtime (python3.11) listening on :{PORT}", flush=True) + server.serve_forever()