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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user