fix: imagePullSecrets, SLESS_ENTRYPOINT, registry secret propagation

- function_controller: добавить RegistrySecret + OperatorNamespace, копировать
  sless-registry-auth в sless-fn-<ns>, выставлять imagePullSecrets в Deployment,
  также обновлять imagePullSecrets при reconcile
- functionjob_controller: fnEnvVars включает SLESS_ENTRYPOINT, runner читает его
- server.js + server.py: читать SLESS_ENTRYPOINT вместо hardcoded handler.js/py
- rbac.yaml: добавить права на secrets
- operator.yaml: v0.1.8
- main.go: передать RegistrySecret + OperatorNamespace в FunctionReconciler
This commit is contained in:
“Naeel”
2026-03-08 11:15:48 +04:00
parent d67b9745a8
commit e6abc490cd
11 changed files with 289 additions and 36 deletions
+61 -5
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-07 // Изменено: 2026-03-08
// FunctionReconciler — основной контроллер оператора. // FunctionReconciler — основной контроллер оператора.
// Следит за CRD Function и управляет lifecycle функции: // Следит за CRD Function и управляет lifecycle функции:
// Pending → Building (запуск kaniko Job) → Ready (образ собран, Deployment создан) / Failed // Pending → Building (запуск kaniko Job) → Ready (образ собран, Deployment создан) / Failed
@@ -28,8 +28,10 @@ import (
// FunctionReconciler reconciles a Function object // FunctionReconciler reconciles a Function object
type FunctionReconciler struct { type FunctionReconciler struct {
client.Client client.Client
Scheme *runtime.Scheme Scheme *runtime.Scheme
Builder *builder.Builder 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 //+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=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=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=namespaces,verbs=get;list;watch;create
//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;create
//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch //+kubebuilder:rbac:groups="",resources=events,verbs=create;patch
// Reconcile — главный цикл управления Function. // 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) desired := r.buildDeployment(fn, deployNS)
existing := &appsv1.Deployment{} existing := &appsv1.Deployment{}
err := r.Get(ctx, client.ObjectKey{Name: fn.Name, Namespace: deployNS}, existing) 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) return ctrl.Result{}, fmt.Errorf("get deployment: %w", err)
} }
// Обновляем образ если изменился (новая сборка) // Обновляем образ и imagePullSecrets если изменились (новая сборка или смена конфига)
existing.Spec.Template.Spec.Containers[0].Image = fn.Status.ImageRef 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 { if err := r.Update(ctx, existing); err != nil {
return ctrl.Result{}, fmt.Errorf("update deployment: %w", err) return ctrl.Result{}, fmt.Errorf("update deployment: %w", err)
} }
@@ -207,7 +220,11 @@ func (r *FunctionReconciler) ensureDeployment(ctx context.Context, fn *slessv1al
// buildDeployment формирует Deployment манифест для функции. // buildDeployment формирует Deployment манифест для функции.
func (r *FunctionReconciler) buildDeployment(fn *slessv1alpha1.Function, namespace string) *appsv1.Deployment { func (r *FunctionReconciler) buildDeployment(fn *slessv1alpha1.Function, namespace string) *appsv1.Deployment {
replicas := int32(1) 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 { for k, v := range fn.Spec.Env {
envVars = append(envVars, corev1.EnvVar{Name: k, Value: v}) 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. // handleDeletion обрабатывает удаление Function: удаляет Deployment и убирает finalizer.
func (r *FunctionReconciler) handleDeletion(ctx context.Context, fn *slessv1alpha1.Function) (ctrl.Result, error) { func (r *FunctionReconciler) handleDeletion(ctx context.Context, fn *slessv1alpha1.Function) (ctrl.Result, error) {
deployNS := "sless-fn-" + fn.Namespace deployNS := "sless-fn-" + fn.Namespace
+19 -7
View File
@@ -200,15 +200,20 @@ func (r *FunctionJobReconciler) syncJobStatus(ctx context.Context, fj *slessv1al
} }
// runtimeRunnerCommand возвращает CMD для запуска одноразового runner вместо HTTP-сервера. // runtimeRunnerCommand возвращает CMD для запуска одноразового runner вместо HTTP-сервера.
// runner читает env SLESS_EVENT, вызывает 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 "nodejs20": case "nodejs20":
// inline runner — не требует отдельного файла в образе // inline runner — не требует отдельного файла в образе.
// SLESS_ENTRYPOINT="module.func": module=имя файла, func=экспортируемая функция
return []string{"node", "-e", ` 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 || '{}'); 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)); console.log(JSON.stringify(r));
process.exit(0); process.exit(0);
}).catch(e => { }).catch(e => {
@@ -218,19 +223,26 @@ Promise.resolve(h.handle(event)).then(r => {
default: // python3.11 default: // python3.11
return []string{"python3", "-c", ` return []string{"python3", "-c", `
import os, json, importlib.util 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) mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) spec.loader.exec_module(mod)
event = json.loads(os.environ.get("SLESS_EVENT", "{}")) event = json.loads(os.environ.get("SLESS_EVENT", "{}"))
result = mod.handle(event) result = getattr(mod, fn_name)(event)
print(json.dumps(result)) print(json.dumps(result))
`} `}
} }
} }
// fnEnvVars преобразует env vars из FunctionSpec в k8s EnvVar slice. // fnEnvVars преобразует env vars из FunctionSpec в k8s EnvVar slice.
// Включает SLESS_ENTRYPOINT чтобы runner.py/runner.js знал какую функцию вызывать.
func fnEnvVars(fn *slessv1alpha1.Function) []corev1.EnvVar { 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 { for k, v := range fn.Spec.Env {
result = append(result, corev1.EnvVar{Name: k, Value: v}) result = append(result, corev1.EnvVar{Name: k, Value: v})
} }
+1 -1
View File
@@ -70,7 +70,7 @@ spec:
containers: containers:
- name: operator - name: operator
# При обновлении версии оператора — менять тег здесь (не latest!) # При обновлении версии оператора — менять тег здесь (не latest!)
image: naeel/sless-operator:v0.1.6 image: naeel/sless-operator:v0.1.8
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый) # Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
+4
View File
@@ -37,6 +37,10 @@ rules:
- apiGroups: [""] - apiGroups: [""]
resources: ["services", "namespaces"] resources: ["services", "namespaces"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Secrets — pull-секрет копируется в sless-fn-* namespace при создании функций
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "create"]
- apiGroups: ["networking.k8s.io"] - apiGroups: ["networking.k8s.io"]
resources: ["ingresses"] resources: ["ingresses"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -34,7 +34,7 @@ resource "sless_job" "hello_run" {
name = "hello-run" name = "hello-run"
function = sless_function.hello_job.name function = sless_function.hello_job.name
event_json = jsonencode({ numbers = [1, 2, 3, 4, 5] }) event_json = jsonencode({ numbers = [1, 2, 3, 4, 5] })
wait_timeout_sec = 120 wait_timeout_sec = 600
run_id = 1 run_id = 1
} }
@@ -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
}
+5 -3
View File
@@ -129,9 +129,11 @@ func main() {
}) })
if err = (&controllers.FunctionReconciler{ if err = (&controllers.FunctionReconciler{
Client: mgr.GetClient(), Client: mgr.GetClient(),
Scheme: mgr.GetScheme(), Scheme: mgr.GetScheme(),
Builder: bldr, Builder: bldr,
RegistrySecret: cfg.RegistrySecret,
OperatorNamespace: "sless",
}).SetupWithManager(mgr); err != nil { }).SetupWithManager(mgr); err != nil {
log.Error("unable to create controller", "controller", "Function", "err", err) log.Error("unable to create controller", "controller", "Function", "err", err)
os.Exit(1) os.Exit(1)
+14 -12
View File
@@ -1,29 +1,31 @@
// Изменено: 2026-03-07 // Изменено: 2026-03-08
// HTTP-обёртка для serverless функций на Node.js 20. // HTTP-обёртка для serverless функций на Node.js 20.
// Загружает handler.js из /app/function/ и вызывает handle(event) на каждый запрос. // Загружает модуль из SLESS_ENTRYPOINT или handler.js по умолчанию.
// Соглашение: handler.js должен экспортировать async функцию handle(event). // Формат SLESS_ENTRYPOINT: "module-name.functionName" (например: handler-http.handle)
//
// Пример handler.js:
// exports.handle = async (event) => {
// return { message: `Hello, ${event.name || 'World'}!` };
// };
'use strict'; 'use strict';
const http = require('http'); const http = require('http');
const path = require('path'); const path = require('path');
const HANDLER_PATH = '/app/function/handler.js';
const PORT = 8080; 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; let userHandle;
try { try {
const mod = require(HANDLER_PATH); const mod = require(HANDLER_PATH);
if (typeof mod.handle !== 'function') { if (typeof mod[funcName] !== 'function') {
throw new Error('handler.js must export a handle(event) function'); throw new Error(`${moduleName} must export a ${funcName}(event) function`);
} }
userHandle = mod.handle; userHandle = mod[funcName];
} catch (err) { } catch (err) {
console.error('Failed to load handler:', err.message); console.error('Failed to load handler:', err.message);
process.exit(1); process.exit(1);
+16 -7
View File
@@ -1,26 +1,35 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# Изменено: 2026-03-07 # Изменено: 2026-03-08
# HTTP-обёртка для serverless функций на Python 3.11. # 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. # Почему importlib: нужно загружать модуль из фиксированного пути вне sys.path.
import sys import sys
import os
import json import json
import importlib.util import importlib.util
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
HANDLER_PATH = "/app/function/handler.py"
PORT = 8080 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(): def load_handler():
# Загружаем модуль пользователя динамически — путь известен только в runtime. # Загружаем модуль пользователя динамически — путь известен только в 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) module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) spec.loader.exec_module(module)
if not hasattr(module, "handle"): if not hasattr(module, _func_name):
raise AttributeError(f"handler.py must define a 'handle(event)' function") raise AttributeError(f"{HANDLER_PATH} must define a '{_func_name}(event)' function")
return module.handle return getattr(module, _func_name)
_handle = load_handler() _handle = load_handler()