feat: sless_service CRD + ServiceReconciler, RBAC fix, split postgres/functions.tf, operator v0.1.41

This commit is contained in:
Naeel
2026-03-20 13:03:12 +03:00
parent dc65f7ab8f
commit 680beb675b
19 changed files with 2448 additions and 693 deletions
+14 -179
View File
@@ -1,8 +1,11 @@
// Изменено: 2026-03-11
// FunctionReconciler — основной контроллер оператора.
// Следит за CRD Function и управляет lifecycle функции:
// Pending → Building (запуск kaniko Job) → Ready (образ собран, Deployment создан) / Failed
// Reconcile вызывается k8s при любом изменении Function объекта.
// Изменено: 2026-03-20 (function-service-split: FunctionReconciler — только build pipeline)
// FunctionReconciler — контроллер Function CRD (sless_function = oneshot/Job).
// Функция = код который выполняется ОДИН РАЗ через k8s Job при каждом вызове.
// Нет Deployment, нет постоянного URL. Вызов — через FunctionJob или invoke API.
// Reconciler отвечает только за:
// 1. Сборку Docker-образа через kaniko (Pending → Building → Ready/Failed)
// 2. Очистку ресурсов при удалении (kaniko Job)
// Deployment/Service/Ingress — в ServiceReconciler (sless_service).
package controllers
@@ -11,15 +14,11 @@ import (
"context"
"fmt"
"io"
"sort"
"strings"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
netv1 "k8s.io/api/networking/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes"
@@ -97,7 +96,9 @@ func (r *FunctionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
case slessv1alpha1.FunctionPhaseBuilding:
return r.checkBuild(ctx, fn)
case slessv1alpha1.FunctionPhaseReady:
return r.ensureDeployment(ctx, fn)
// Function = oneshot. После успешной сборки образ готов — Deployment не создаём.
// Вызов через FunctionJob или invoke API (Job per call).
return ctrl.Result{}, nil
}
return ctrl.Result{}, nil
@@ -184,180 +185,14 @@ func (r *FunctionReconciler) checkBuild(ctx context.Context, fn *slessv1alpha1.F
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
// ensureDeployment создаёт или обновляет Deployment для HTTP функции.
// Deployment запускается в отдельном namespace sless-fn-{namespace}.
func (r *FunctionReconciler) ensureDeployment(ctx context.Context, fn *slessv1alpha1.Function) (ctrl.Result, error) {
deployNS := "sless-fn-" + fn.Namespace
// Создаём namespace для функций если не существует
ns := &corev1.Namespace{}
if err := r.Get(ctx, client.ObjectKey{Name: deployNS}, ns); err != nil {
if errors.IsNotFound(err) {
ns = &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: deployNS}}
if err := r.Create(ctx, ns); err != nil {
return ctrl.Result{}, fmt.Errorf("create function namespace: %w", err)
}
// Создаём Harbor-проект для namespace сразу при создании k8s NS (best-effort).
// Если не удалось — EnsureProject повторит вызов внутри Build().
if r.HarborClient != nil {
if err := r.HarborClient.EnsureProject(ctx, fn.Namespace); err != nil {
log.FromContext(ctx).Error(err, "harbor ensure project on ns create", "project", fn.Namespace)
}
}
} else {
return ctrl.Result{}, fmt.Errorf("get function namespace: %w", err)
}
}
// Обеспечиваем наличие 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)
if errors.IsNotFound(err) {
if err := r.Create(ctx, desired); err != nil {
return ctrl.Result{}, fmt.Errorf("create deployment: %w", err)
}
return ctrl.Result{}, nil
}
if err != nil {
return ctrl.Result{}, fmt.Errorf("get deployment: %w", err)
}
// Обновляем образ, env и imagePullSecrets при пересборке или изменении конфига.
// Тег образа уникален per build (sha256 от s3Key) → imagePullPolicy: IfNotPresent
// корректно подтягивает новый образ без дополнительных хаков.
// Env обновляем целиком — иначе изменение entrypoint/env_vars не применяется.
existing.Spec.Template.Spec.Containers[0].Image = fn.Status.ImageRef
existing.Spec.Template.Spec.Containers[0].Env = desired.Spec.Template.Spec.Containers[0].Env
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)
}
return ctrl.Result{}, nil
}
// Изменено: 2026-03-11// buildDeployment формирует Deployment манифест для функции.
func (r *FunctionReconciler) buildDeployment(fn *slessv1alpha1.Function, namespace string) *appsv1.Deployment {
replicas := int32(1)
envVars := []corev1.EnvVar{
// SLESS_ENTRYPOINT сообщает server.py/server.js какой файл и функцию загружать.
// Формат: "module-name.funcName" (например: handler-http.handle)
{Name: "SLESS_ENTRYPOINT", Value: fn.Spec.Entrypoint},
}
// Сортируем ключи env vars для стабильного порядка в Pod spec.
// map range в Go — недетерминирован: разный порядок при каждом вызове.
// Нестабильный порядок → k8s видит изменение контейнера → лишние rollout'ы.
keys := make([]string, 0, len(fn.Spec.Env))
for k := range fn.Spec.Env {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
envVars = append(envVars, corev1.EnvVar{Name: k, Value: fn.Spec.Env[k]})
}
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: fn.Name,
Namespace: namespace,
Labels: map[string]string{"app": fn.Name, "managed-by": "sless"},
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": fn.Name}},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": fn.Name}},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: fn.Name,
Image: fn.Status.ImageRef,
Env: envVars,
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{
corev1.ResourceMemory: resource.MustParse(fmt.Sprintf("%dMi", fn.Spec.MemoryMB)),
},
},
},
},
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, Service, Ingress и убирает finalizer.
// ВАЖНО: Namespace sless-fn-{userNS} НЕ удаляется — он принадлежит пользователю на всё время его существования.
// handleDeletion обрабатывает удаление Function: убивает kaniko Job и убирает finalizer.
// Deployment/Service/Ingress Function не создаёт — они принадлежат Service CRD.
func (r *FunctionReconciler) handleDeletion(ctx context.Context, fn *slessv1alpha1.Function) (ctrl.Result, error) {
deployNS := "sless-fn-" + fn.Namespace
dep := &appsv1.Deployment{}
if err := r.Get(ctx, client.ObjectKey{Name: fn.Name, Namespace: deployNS}, dep); err == nil {
_ = r.Delete(ctx, dep)
}
// Если функция удалена в процессе сборки — убиваем kaniko Job.
// Без этого Job продолжит работу, займёт CPU/память и запушит образ которым никто не воспользуется.
// Убиваем kaniko Job если сборка шла в момент удаления
if jobName := fn.Annotations["sless.kube5s.ru/build-job"]; jobName != "" {
_ = r.Builder.Cleanup(ctx, jobName)
}
// Удаляем Service и Ingress — созданы HTTP триггером, но именованы по функции.
// Если function_controller не удалит их, Ingress остаётся после destroy → 502.
svc := &corev1.Service{}
if err := r.Get(ctx, client.ObjectKey{Name: fn.Name, Namespace: deployNS}, svc); err == nil {
_ = r.Delete(ctx, svc)
}
ing := &netv1.Ingress{}
if err := r.Get(ctx, client.ObjectKey{Name: fn.Name, Namespace: deployNS}, ing); err == nil {
_ = r.Delete(ctx, ing)
}
fn.Finalizers = removeString(fn.Finalizers, finalizerName)
if err := r.Update(ctx, fn); err != nil {
return ctrl.Result{}, fmt.Errorf("remove finalizer: %w", err)