feat: REST API (gorilla/mux + slog) + trigger controller + main.go wiring

- 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
This commit is contained in:
“Naeel”
2026-03-07 08:46:10 +04:00
parent 5f25f61948
commit 43e7d0ea48
21 changed files with 1720 additions and 112 deletions
+254 -27
View File
@@ -1,59 +1,286 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Изменено: 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
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 is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
// TODO(user): Modify the Reconcile function to compare the state specified by
// the Function object against the actual cluster state, and then
// perform operations to make the cluster state reflect the state specified by
// the user.
//
// For more details, check Reconcile and its Result here:
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.14.1/pkg/reconcile
// Reconcile — главный цикл управления Function.
// Логика: читаем текущее состояние → определяем что нужно сделать → делаем.
func (r *FunctionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
_ = log.FromContext(ctx)
logger := log.FromContext(ctx)
// TODO(user): your logic here
// Читаем 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).