diff --git a/controllers/function_controller.go b/controllers/function_controller.go index ed35861..8e990cc 100644 --- a/controllers/function_controller.go +++ b/controllers/function_controller.go @@ -1,4 +1,4 @@ -// Изменено: 2026-03-07 +// Изменено: 2026-03-08 // FunctionReconciler — основной контроллер оператора. // Следит за CRD Function и управляет lifecycle функции: // Pending → Building (запуск kaniko Job) → Ready (образ собран, Deployment создан) / Failed @@ -28,8 +28,10 @@ import ( // FunctionReconciler reconciles a Function object type FunctionReconciler struct { client.Client - Scheme *runtime.Scheme - Builder *builder.Builder + Scheme *runtime.Scheme + Builder *builder.Builder + RegistrySecret string // имя Secret с docker credentials (для imagePullSecrets в подах функций) + OperatorNamespace string // namespace оператора — откуда копируем RegistrySecret в sless-fn-* } //+kubebuilder:rbac:groups=sless.kube5s.ru,resources=functions,verbs=get;list;watch;create;update;patch;delete @@ -38,6 +40,7 @@ type FunctionReconciler struct { //+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch;create +//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;create //+kubebuilder:rbac:groups="",resources=events,verbs=create;patch // Reconcile — главный цикл управления Function. @@ -183,6 +186,15 @@ func (r *FunctionReconciler) ensureDeployment(ctx context.Context, fn *slessv1al } } + // Обеспечиваем наличие registry pull-секрета в namespace функций. + // Без него kubelet не сможет pull-нуть private образ из Harbor. + if r.RegistrySecret != "" && r.OperatorNamespace != "" { + if err := r.ensureRegistrySecret(ctx, deployNS); err != nil { + // Не фатальная ошибка — логируем, но продолжаем + log.FromContext(ctx).Error(err, "failed to ensure registry secret", "ns", deployNS) + } + } + desired := r.buildDeployment(fn, deployNS) existing := &appsv1.Deployment{} err := r.Get(ctx, client.ObjectKey{Name: fn.Name, Namespace: deployNS}, existing) @@ -196,8 +208,9 @@ func (r *FunctionReconciler) ensureDeployment(ctx context.Context, fn *slessv1al return ctrl.Result{}, fmt.Errorf("get deployment: %w", err) } - // Обновляем образ если изменился (новая сборка) + // Обновляем образ и imagePullSecrets если изменились (новая сборка или смена конфига) existing.Spec.Template.Spec.Containers[0].Image = fn.Status.ImageRef + existing.Spec.Template.Spec.ImagePullSecrets = desired.Spec.Template.Spec.ImagePullSecrets if err := r.Update(ctx, existing); err != nil { return ctrl.Result{}, fmt.Errorf("update deployment: %w", err) } @@ -207,7 +220,11 @@ func (r *FunctionReconciler) ensureDeployment(ctx context.Context, fn *slessv1al // buildDeployment формирует Deployment манифест для функции. func (r *FunctionReconciler) buildDeployment(fn *slessv1alpha1.Function, namespace string) *appsv1.Deployment { replicas := int32(1) - envVars := []corev1.EnvVar{} + envVars := []corev1.EnvVar{ + // SLESS_ENTRYPOINT сообщает server.py/server.js какой файл и функцию загружать. + // Формат: "module-name.funcName" (например: handler-http.handle) + {Name: "SLESS_ENTRYPOINT", Value: fn.Spec.Entrypoint}, + } for k, v := range fn.Spec.Env { envVars = append(envVars, corev1.EnvVar{Name: k, Value: v}) } @@ -236,12 +253,51 @@ func (r *FunctionReconciler) buildDeployment(fn *slessv1alpha1.Function, namespa }, }, }, + ImagePullSecrets: func() []corev1.LocalObjectReference { + if r.RegistrySecret != "" { + return []corev1.LocalObjectReference{{Name: r.RegistrySecret}} + } + return nil + }(), }, }, }, } } +// ensureRegistrySecret копирует pull-секрет из namespace оператора в namespace функций. +// Вызывается при каждом reconcile — если секрет уже есть, ничего не делает. +func (r *FunctionReconciler) ensureRegistrySecret(ctx context.Context, targetNS string) error { + // Проверяем что секрет уже есть в целевом namespace + existing := &corev1.Secret{} + if err := r.Get(ctx, client.ObjectKey{Name: r.RegistrySecret, Namespace: targetNS}, existing); err == nil { + return nil // уже есть + } else if !errors.IsNotFound(err) { + return fmt.Errorf("check secret: %w", err) + } + + // Копируем из namespace оператора + src := &corev1.Secret{} + if err := r.Get(ctx, client.ObjectKey{Name: r.RegistrySecret, Namespace: r.OperatorNamespace}, src); err != nil { + return fmt.Errorf("get source secret from %s: %w", r.OperatorNamespace, err) + } + + copy := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: r.RegistrySecret, + Namespace: targetNS, + }, + Type: src.Type, + Data: src.Data, + } + if err := r.Create(ctx, copy); err != nil { + if !errors.IsAlreadyExists(err) { + return fmt.Errorf("create secret in %s: %w", targetNS, err) + } + } + return nil +} + // handleDeletion обрабатывает удаление Function: удаляет Deployment и убирает finalizer. func (r *FunctionReconciler) handleDeletion(ctx context.Context, fn *slessv1alpha1.Function) (ctrl.Result, error) { deployNS := "sless-fn-" + fn.Namespace diff --git a/controllers/functionjob_controller.go b/controllers/functionjob_controller.go index 7d34a59..320fa4c 100644 --- a/controllers/functionjob_controller.go +++ b/controllers/functionjob_controller.go @@ -200,15 +200,20 @@ func (r *FunctionJobReconciler) syncJobStatus(ctx context.Context, fj *slessv1al } // runtimeRunnerCommand возвращает CMD для запуска одноразового runner вместо HTTP-сервера. -// runner читает env SLESS_EVENT, вызывает handle(event) один раз и завершается. +// runner читает env SLESS_EVENT и SLESS_ENTRYPOINT, вызывает handle(event) один раз и завершается. func runtimeRunnerCommand(runtime string) []string { switch runtime { case "nodejs20": - // inline runner — не требует отдельного файла в образе + // inline runner — не требует отдельного файла в образе. + // SLESS_ENTRYPOINT="module.func": module=имя файла, func=экспортируемая функция return []string{"node", "-e", ` -const h = require('/app/function/handler.js'); +const ep = process.env.SLESS_ENTRYPOINT || 'handler.handle'; +const dot = ep.lastIndexOf('.'); +const mod = ep.slice(0, dot >= 0 ? dot : ep.length); +const fn = dot >= 0 ? ep.slice(dot + 1) : 'handle'; +const h = require('/app/function/' + mod); const event = JSON.parse(process.env.SLESS_EVENT || '{}'); -Promise.resolve(h.handle(event)).then(r => { +Promise.resolve(h[fn](event)).then(r => { console.log(JSON.stringify(r)); process.exit(0); }).catch(e => { @@ -218,19 +223,26 @@ Promise.resolve(h.handle(event)).then(r => { default: // python3.11 return []string{"python3", "-c", ` import os, json, importlib.util -spec = importlib.util.spec_from_file_location("handler", "/app/function/handler.py") +ep = os.environ.get("SLESS_ENTRYPOINT", "handler.handle") +dot = ep.rfind(".") +mod_name = ep[:dot] if dot >= 0 else ep +fn_name = ep[dot+1:] if dot >= 0 else "handle" +spec = importlib.util.spec_from_file_location(mod_name, "/app/function/" + mod_name + ".py") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) event = json.loads(os.environ.get("SLESS_EVENT", "{}")) -result = mod.handle(event) +result = getattr(mod, fn_name)(event) print(json.dumps(result)) `} } } // fnEnvVars преобразует env vars из FunctionSpec в k8s EnvVar slice. +// Включает SLESS_ENTRYPOINT чтобы runner.py/runner.js знал какую функцию вызывать. func fnEnvVars(fn *slessv1alpha1.Function) []corev1.EnvVar { - var result []corev1.EnvVar + result := []corev1.EnvVar{ + {Name: "SLESS_ENTRYPOINT", Value: fn.Spec.Entrypoint}, + } for k, v := range fn.Spec.Env { result = append(result, corev1.EnvVar{Name: k, Value: v}) } diff --git a/deployments/k8s/operator.yaml b/deployments/k8s/operator.yaml index 58887e7..59e3359 100644 --- a/deployments/k8s/operator.yaml +++ b/deployments/k8s/operator.yaml @@ -70,7 +70,7 @@ spec: containers: - name: operator # При обновлении версии оператора — менять тег здесь (не latest!) - image: naeel/sless-operator:v0.1.6 + image: naeel/sless-operator:v0.1.8 # Always — чтобы всегда тянуть по точному тегу (не кешировать старый) imagePullPolicy: Always ports: diff --git a/deployments/k8s/rbac.yaml b/deployments/k8s/rbac.yaml index c08d59e..d544522 100644 --- a/deployments/k8s/rbac.yaml +++ b/deployments/k8s/rbac.yaml @@ -37,6 +37,10 @@ rules: - apiGroups: [""] resources: ["services", "namespaces"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Secrets — pull-секрет копируется в sless-fn-* namespace при создании функций + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "create"] - apiGroups: ["networking.k8s.io"] resources: ["ingresses"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] diff --git a/examples/hello-node/handler-http.zip b/examples/hello-node/handler-http.zip new file mode 100644 index 0000000..c0c865e Binary files /dev/null and b/examples/hello-node/handler-http.zip differ diff --git a/examples/hello-node/handler-job.zip b/examples/hello-node/handler-job.zip new file mode 100644 index 0000000..283b2cf Binary files /dev/null and b/examples/hello-node/handler-job.zip differ diff --git a/examples/hello-node/job.tf b/examples/hello-node/job.tf index 8687f77..5692465 100644 --- a/examples/hello-node/job.tf +++ b/examples/hello-node/job.tf @@ -34,7 +34,7 @@ resource "sless_job" "hello_run" { name = "hello-run" function = sless_function.hello_job.name event_json = jsonencode({ numbers = [1, 2, 3, 4, 5] }) - wait_timeout_sec = 120 + wait_timeout_sec = 600 run_id = 1 } diff --git a/examples/hello-node/terraform.tfstate.1772954131.backup b/examples/hello-node/terraform.tfstate.1772954131.backup new file mode 100644 index 0000000..f804125 --- /dev/null +++ b/examples/hello-node/terraform.tfstate.1772954131.backup @@ -0,0 +1,168 @@ +{ + "version": 4, + "terraform_version": "1.12.2", + "serial": 22, + "lineage": "d12fc078-7aee-39d1-629d-358c3c135820", + "outputs": { + "trigger_url": { + "value": "https://sless-api.kube5s.ru/fn/default/hello-http", + "type": "string" + } + }, + "resources": [ + { + "mode": "data", + "type": "archive_file", + "name": "handler_http", + "provider": "provider[\"registry.terraform.io/hashicorp/archive\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "exclude_symlink_directories": null, + "excludes": null, + "id": "16650367fe534ed2feb81be322fd4a9d80f77388", + "output_base64sha256": "/fY9RigPle6Yx9R9B9yEYmi5+jgL6PV3fmPlVK5ia3g=", + "output_base64sha512": "yKvMpTCZBynqV3LlE3wTZGMIS0EG0tY8LE+1iIETCuWGoc+bv4+Hlnve14bBWGOnvQHdqE84y4UDi8Pmnz1A2A==", + "output_file_mode": null, + "output_md5": "a74ae4ccb7337659439eacaf1831194d", + "output_path": "./handler-http.zip", + "output_sha": "16650367fe534ed2feb81be322fd4a9d80f77388", + "output_sha256": "fdf63d46280f95ee98c7d47d07dc846268b9fa380be8f5777e63e554ae626b78", + "output_sha512": "c8abcca530990729ea5772e5137c136463084b4106d2d63c2c4fb58881130ae586a1cf9bbf8f87967bded786c15863a7bd01dda84f38cb85038bc3e69f3d40d8", + "output_size": 409, + "source": [], + "source_content": null, + "source_content_filename": null, + "source_dir": null, + "source_file": "./code/handler-http.js", + "type": "zip" + }, + "sensitive_attributes": [], + "identity_schema_version": 0 + } + ] + }, + { + "mode": "data", + "type": "archive_file", + "name": "handler_job", + "provider": "provider[\"registry.terraform.io/hashicorp/archive\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "exclude_symlink_directories": null, + "excludes": null, + "id": "27327ec4d4ab6d5d6fdc3f82a5ab768c68146a66", + "output_base64sha256": "cI+JbO5lWW+3qo4DSDg5HXLQS7EXHze2W6vGCNk0iOI=", + "output_base64sha512": "4D841Y2OT5EVlRbi/NwGh7SHnRzKgZ6AF1Rx+AIspPKthrbsZi8oGY6qLYe/NJ4t46j1Y8WkZ4tJ6iBpL5g7uw==", + "output_file_mode": null, + "output_md5": "2c5c498c77ec002df7cbeac94f626af8", + "output_path": "./handler-job.zip", + "output_sha": "27327ec4d4ab6d5d6fdc3f82a5ab768c68146a66", + "output_sha256": "708f896cee65596fb7aa8e034838391d72d04bb1171f37b65babc608d93488e2", + "output_sha512": "e03f38d58d8e4f91159516e2fcdc0687b4879d1cca819e80175471f8022ca4f2ad86b6ec662f28198eaa2d87bf349e2de3a8f563c5a4678b49ea20692f983bbb", + "output_size": 489, + "source": [], + "source_content": null, + "source_content_filename": null, + "source_dir": null, + "source_file": "./code/handler-job.js", + "type": "zip" + }, + "sensitive_attributes": [], + "identity_schema_version": 0 + } + ] + }, + { + "mode": "managed", + "type": "sless_function", + "name": "hello_http", + "provider": "provider[\"terra.k8c.ru/naeel/sless\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "build_timeout_sec": 300, + "code_hash": "a74ae4ccb7337659439eacaf1831194d", + "code_path": "./handler-http.zip", + "entrypoint": "handler-http.handle", + "env_vars": null, + "image_ref": "pearlharbor.registryk8s.services.ngcloud.ru/sless/sless-default-hello-http:latest", + "memory_mb": 128, + "name": "hello-http", + "namespace": "default", + "phase": "Ready", + "runtime": "nodejs20", + "timeout_sec": 30 + }, + "sensitive_attributes": [], + "identity_schema_version": 0, + "dependencies": [ + "data.archive_file.handler_http" + ] + } + ] + }, + { + "mode": "managed", + "type": "sless_function", + "name": "hello_job", + "provider": "provider[\"terra.k8c.ru/naeel/sless\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "build_timeout_sec": 300, + "code_hash": "2c5c498c77ec002df7cbeac94f626af8", + "code_path": "./handler-job.zip", + "entrypoint": "handler-job.handle", + "env_vars": null, + "image_ref": "pearlharbor.registryk8s.services.ngcloud.ru/sless/sless-default-hello-job:latest", + "memory_mb": 128, + "name": "hello-job", + "namespace": "default", + "phase": "Ready", + "runtime": "nodejs20", + "timeout_sec": 30 + }, + "sensitive_attributes": [], + "identity_schema_version": 0, + "dependencies": [ + "data.archive_file.handler_job" + ] + } + ] + }, + { + "mode": "managed", + "type": "sless_trigger", + "name": "hello_http", + "provider": "provider[\"terra.k8c.ru/naeel/sless\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "active": true, + "enabled": true, + "function": "hello-http", + "name": "hello-http-trigger", + "namespace": "default", + "schedule": null, + "type": "http", + "url": "https://sless-api.kube5s.ru/fn/default/hello-http" + }, + "sensitive_attributes": [], + "identity_schema_version": 0, + "dependencies": [ + "data.archive_file.handler_http", + "sless_function.hello_http" + ] + } + ] + } + ], + "check_results": null +} diff --git a/main.go b/main.go index 286a268..38c956d 100644 --- a/main.go +++ b/main.go @@ -129,9 +129,11 @@ func main() { }) if err = (&controllers.FunctionReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Builder: bldr, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Builder: bldr, + RegistrySecret: cfg.RegistrySecret, + OperatorNamespace: "sless", }).SetupWithManager(mgr); err != nil { log.Error("unable to create controller", "controller", "Function", "err", err) os.Exit(1) diff --git a/runtimes/nodejs20/server.js b/runtimes/nodejs20/server.js index 290edc3..fcaab09 100644 --- a/runtimes/nodejs20/server.js +++ b/runtimes/nodejs20/server.js @@ -1,29 +1,31 @@ -// Изменено: 2026-03-07 +// Изменено: 2026-03-08 // HTTP-обёртка для serverless функций на Node.js 20. -// Загружает handler.js из /app/function/ и вызывает handle(event) на каждый запрос. -// Соглашение: handler.js должен экспортировать async функцию handle(event). -// -// Пример handler.js: -// exports.handle = async (event) => { -// return { message: `Hello, ${event.name || 'World'}!` }; -// }; +// Загружает модуль из SLESS_ENTRYPOINT или handler.js по умолчанию. +// Формат SLESS_ENTRYPOINT: "module-name.functionName" (например: handler-http.handle) 'use strict'; const http = require('http'); const path = require('path'); -const HANDLER_PATH = '/app/function/handler.js'; const PORT = 8080; +// Разбираем SLESS_ENTRYPOINT="module.func" → файл и имя функции. +// Fallback: handler.js + handle — для обратной совместимости. +const entrypoint = process.env.SLESS_ENTRYPOINT || 'handler.handle'; +const dotIdx = entrypoint.lastIndexOf('.'); +const moduleName = dotIdx >= 0 ? entrypoint.slice(0, dotIdx) : entrypoint; +const funcName = dotIdx >= 0 ? entrypoint.slice(dotIdx + 1) : 'handle'; +const HANDLER_PATH = path.join('/app/function', moduleName); + // Загружаем модуль пользователя один раз при старте — не на каждый запрос let userHandle; try { const mod = require(HANDLER_PATH); - if (typeof mod.handle !== 'function') { - throw new Error('handler.js must export a handle(event) function'); + if (typeof mod[funcName] !== 'function') { + throw new Error(`${moduleName} must export a ${funcName}(event) function`); } - userHandle = mod.handle; + userHandle = mod[funcName]; } catch (err) { console.error('Failed to load handler:', err.message); process.exit(1); diff --git a/runtimes/python3.11/server.py b/runtimes/python3.11/server.py index 74bb563..7307d23 100644 --- a/runtimes/python3.11/server.py +++ b/runtimes/python3.11/server.py @@ -1,26 +1,35 @@ #!/usr/bin/env python3 -# Изменено: 2026-03-07 +# Изменено: 2026-03-08 # HTTP-обёртка для serverless функций на Python 3.11. -# Загружает handler.py из /app/function/ и вызывает handle(event) на каждый запрос. +# Загружает модуль из SLESS_ENTRYPOINT или handler.py по умолчанию. +# Формат SLESS_ENTRYPOINT: "module_name.func_name" (например: handler.handle) # Почему importlib: нужно загружать модуль из фиксированного пути вне sys.path. import sys +import os import json import importlib.util from http.server import HTTPServer, BaseHTTPRequestHandler -HANDLER_PATH = "/app/function/handler.py" PORT = 8080 +# Разбираем SLESS_ENTRYPOINT="module.func" → файл и имя функции. +# Fallback: handler.py + handle — для обратной совместимости. +_entrypoint = os.environ.get("SLESS_ENTRYPOINT", "handler.handle") +_dot_idx = _entrypoint.rfind(".") +_module_name = _entrypoint[:_dot_idx] if _dot_idx >= 0 else _entrypoint +_func_name = _entrypoint[_dot_idx + 1:] if _dot_idx >= 0 else "handle" +HANDLER_PATH = f"/app/function/{_module_name}.py" + def load_handler(): # Загружаем модуль пользователя динамически — путь известен только в runtime. - spec = importlib.util.spec_from_file_location("handler", HANDLER_PATH) + spec = importlib.util.spec_from_file_location(_module_name, 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 + if not hasattr(module, _func_name): + raise AttributeError(f"{HANDLER_PATH} must define a '{_func_name}(event)' function") + return getattr(module, _func_name) _handle = load_handler()