- controllers/trigger_controller.go: полная реализация, HTTP->Ingress+Service, Cron->CronJob
- internal/config: добавлены IngressHost, APIToken
- internal/api/router.go: gorilla/mux роутер /v1/namespaces/{ns}/...
- internal/api/handler/: functions, triggers, invocations, base handler с slog
- internal/api/middleware/: auth (Bearer token) + logging (slog)
- main.go: запуск operator + HTTP сервера параллельно
- go.sum: добавлен gorilla/mux v1.8.1
290 lines
11 KiB
Go
290 lines
11 KiB
Go
// Изменено: 2026-03-07
|
||
// FunctionReconciler — основной контроллер оператора.
|
||
// Следит за CRD Function и управляет lifecycle функции:
|
||
// Pending → Building (запуск kaniko Job) → Ready (образ собран, Deployment создан) / Failed
|
||
// Reconcile вызывается k8s при любом изменении Function объекта.
|
||
|
||
package controllers
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"time"
|
||
|
||
appsv1 "k8s.io/api/apps/v1"
|
||
corev1 "k8s.io/api/core/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"
|
||
ctrl "sigs.k8s.io/controller-runtime"
|
||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||
|
||
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
|
||
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/builder"
|
||
)
|
||
|
||
// FunctionReconciler reconciles a Function object
|
||
type FunctionReconciler struct {
|
||
client.Client
|
||
Scheme *runtime.Scheme
|
||
Builder *builder.Builder
|
||
}
|
||
|
||
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=functions,verbs=get;list;watch;create;update;patch;delete
|
||
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=functions/status,verbs=get;update;patch
|
||
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=functions/finalizers,verbs=update
|
||
//+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=events,verbs=create;patch
|
||
|
||
// Reconcile — главный цикл управления Function.
|
||
// Логика: читаем текущее состояние → определяем что нужно сделать → делаем.
|
||
func (r *FunctionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||
logger := log.FromContext(ctx)
|
||
|
||
// Читаем Function объект из k8s
|
||
fn := &slessv1alpha1.Function{}
|
||
if err := r.Get(ctx, req.NamespacedName, fn); err != nil {
|
||
if errors.IsNotFound(err) {
|
||
// Объект удалён — ничего делать не надо, finalizer уже отработал
|
||
return ctrl.Result{}, nil
|
||
}
|
||
return ctrl.Result{}, fmt.Errorf("get function: %w", err)
|
||
}
|
||
|
||
// Обрабатываем удаление через finalizer
|
||
if !fn.DeletionTimestamp.IsZero() {
|
||
return r.handleDeletion(ctx, fn)
|
||
}
|
||
|
||
// Добавляем finalizer при первом создании чтобы обработать удаление
|
||
if !containsString(fn.Finalizers, finalizerName) {
|
||
fn.Finalizers = append(fn.Finalizers, finalizerName)
|
||
if err := r.Update(ctx, fn); err != nil {
|
||
return ctrl.Result{}, fmt.Errorf("add finalizer: %w", err)
|
||
}
|
||
return ctrl.Result{Requeue: true}, nil
|
||
}
|
||
|
||
// Определяем что делать в зависимости от текущей фазы
|
||
switch fn.Status.Phase {
|
||
case "", slessv1alpha1.FunctionPhasePending:
|
||
// Новая функция или сброшена в Pending — запускаем сборку образа
|
||
logger.Info("starting build", "function", fn.Name)
|
||
return r.startBuild(ctx, fn)
|
||
|
||
case slessv1alpha1.FunctionPhaseBuilding:
|
||
// Сборка уже запущена — проверяем статус Job'а
|
||
return r.checkBuild(ctx, fn)
|
||
|
||
case slessv1alpha1.FunctionPhaseReady:
|
||
// Функция готова — проверяем что Deployment существует и актуален
|
||
return r.ensureDeployment(ctx, fn)
|
||
|
||
case slessv1alpha1.FunctionPhaseFailed:
|
||
// Ничего не делаем — пользователь должен исправить spec и обновить объект
|
||
return ctrl.Result{}, nil
|
||
}
|
||
|
||
return ctrl.Result{}, nil
|
||
}
|
||
|
||
const finalizerName = "sless.kube5s.ru/finalizer"
|
||
|
||
// startBuild переводит функцию в фазу Building и запускает kaniko Job.
|
||
func (r *FunctionReconciler) startBuild(ctx context.Context, fn *slessv1alpha1.Function) (ctrl.Result, error) {
|
||
jobName, err := r.Builder.Build(ctx, fn.Namespace, fn.Name, fn.Spec.S3Key)
|
||
if err != nil {
|
||
return r.setFailed(ctx, fn, fmt.Sprintf("failed to start build: %v", err))
|
||
}
|
||
|
||
// Сохраняем имя Job'а в аннотации чтобы отслеживать в следующем reconcile
|
||
if fn.Annotations == nil {
|
||
fn.Annotations = map[string]string{}
|
||
}
|
||
fn.Annotations["sless.kube5s.ru/build-job"] = jobName
|
||
|
||
fn.Status.Phase = slessv1alpha1.FunctionPhaseBuilding
|
||
fn.Status.Message = "Building image: " + jobName
|
||
if err := r.Status().Update(ctx, fn); err != nil {
|
||
return ctrl.Result{}, fmt.Errorf("update status to building: %w", err)
|
||
}
|
||
if err := r.Update(ctx, fn); err != nil {
|
||
return ctrl.Result{}, fmt.Errorf("update annotations: %w", err)
|
||
}
|
||
|
||
// Перепроверяем через 10 секунд
|
||
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
|
||
}
|
||
|
||
// checkBuild проверяет статус build Job'а.
|
||
func (r *FunctionReconciler) checkBuild(ctx context.Context, fn *slessv1alpha1.Function) (ctrl.Result, error) {
|
||
jobName := fn.Annotations["sless.kube5s.ru/build-job"]
|
||
if jobName == "" {
|
||
// Аннотация потерялась — перезапускаем сборку
|
||
fn.Status.Phase = slessv1alpha1.FunctionPhasePending
|
||
_ = r.Status().Update(ctx, fn)
|
||
return ctrl.Result{Requeue: true}, nil
|
||
}
|
||
|
||
status, err := r.Builder.JobStatus(ctx, jobName)
|
||
if err != nil {
|
||
return ctrl.Result{}, fmt.Errorf("check build job: %w", err)
|
||
}
|
||
|
||
switch status {
|
||
case "running":
|
||
// Ещё идёт — проверим через 10 секунд
|
||
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
|
||
|
||
case "succeeded":
|
||
imageRef := r.Builder.ImageRef(fn.Namespace, fn.Name, fn.Spec.S3Key)
|
||
fn.Status.Phase = slessv1alpha1.FunctionPhaseReady
|
||
fn.Status.ImageRef = imageRef
|
||
fn.Status.Message = ""
|
||
now := metav1.Now()
|
||
fn.Status.LastBuiltAt = &now
|
||
if err := r.Status().Update(ctx, fn); err != nil {
|
||
return ctrl.Result{}, fmt.Errorf("update status to ready: %w", err)
|
||
}
|
||
// Чистим завершённый Job
|
||
_ = r.Builder.Cleanup(ctx, jobName)
|
||
return ctrl.Result{Requeue: true}, nil
|
||
|
||
case "failed":
|
||
return r.setFailed(ctx, fn, "build job failed")
|
||
}
|
||
|
||
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)
|
||
}
|
||
} else {
|
||
return ctrl.Result{}, fmt.Errorf("get function namespace: %w", err)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// Обновляем образ если изменился (новая сборка)
|
||
existing.Spec.Template.Spec.Containers[0].Image = fn.Status.ImageRef
|
||
if err := r.Update(ctx, existing); err != nil {
|
||
return ctrl.Result{}, fmt.Errorf("update deployment: %w", err)
|
||
}
|
||
return ctrl.Result{}, nil
|
||
}
|
||
|
||
// buildDeployment формирует Deployment манифест для функции.
|
||
func (r *FunctionReconciler) buildDeployment(fn *slessv1alpha1.Function, namespace string) *appsv1.Deployment {
|
||
replicas := int32(1)
|
||
envVars := []corev1.EnvVar{}
|
||
for k, v := range fn.Spec.Env {
|
||
envVars = append(envVars, corev1.EnvVar{Name: k, Value: v})
|
||
}
|
||
|
||
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)),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
// handleDeletion обрабатывает удаление Function: удаляет Deployment и убирает finalizer.
|
||
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)
|
||
}
|
||
|
||
fn.Finalizers = removeString(fn.Finalizers, finalizerName)
|
||
if err := r.Update(ctx, fn); err != nil {
|
||
return ctrl.Result{}, fmt.Errorf("remove finalizer: %w", err)
|
||
}
|
||
return ctrl.Result{}, nil
|
||
}
|
||
|
||
// setFailed переводит функцию в фазу Failed с сообщением об ошибке.
|
||
func (r *FunctionReconciler) setFailed(ctx context.Context, fn *slessv1alpha1.Function, msg string) (ctrl.Result, error) {
|
||
fn.Status.Phase = slessv1alpha1.FunctionPhaseFailed
|
||
fn.Status.Message = msg
|
||
if err := r.Status().Update(ctx, fn); err != nil {
|
||
return ctrl.Result{}, fmt.Errorf("update status to failed: %w", err)
|
||
}
|
||
return ctrl.Result{}, nil
|
||
}
|
||
|
||
func containsString(slice []string, s string) bool {
|
||
for _, v := range slice {
|
||
if v == s {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func removeString(slice []string, s string) []string {
|
||
var result []string
|
||
for _, v := range slice {
|
||
if v != s {
|
||
result = append(result, v)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// SetupWithManager sets up the controller with the Manager.
|
||
func (r *FunctionReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||
return ctrl.NewControllerManagedBy(mgr).
|
||
For(&slessv1alpha1.Function{}).
|
||
Complete(r)
|
||
}
|