Files
sless/controllers/service_controller.go

500 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Создано: 2026-03-20
// ServiceReconciler — контроллер для Service CRD (sless_service).
// Service = долгоживущий HTTP-сервис с постоянным URL.
// Lifecycle: Pending → Building (kaniko) → Ready (Deployment+k8s Service+Ingress, URL в Status) / Failed
//
// Отличие от Function:
// Function = oneshot, запускается k8s Job через FunctionJob/invoke.
// Service = HTTP-сервис, Deployment всегда запущен, URL доступен без отдельного sless_trigger.
package controllers
import (
"bytes"
"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"
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"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/harbor"
)
// ServiceReconciler reconciles a Service object
type ServiceReconciler struct {
client.Client
Scheme *runtime.Scheme
Builder *builder.Builder
KubeClient kubernetes.Interface // typed client для чтения логов build-подов
RegistrySecret string // имя Secret с docker credentials
OperatorNamespace string // откуда копируем RegistrySecret в sless-fn-*
HarborClient *harbor.Client // nil — EnsureProject пропускается
ExternalURL string // базовый URL для Status.URL: {ExternalURL}/fn/{ns}/{name}
IngressHost string // fallback домен если ExternalURL не задан
}
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=services,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=services/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=services/finalizers,verbs=update
//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses,verbs=get;list;watch;create;update;patch;delete
const serviceFinalizerName = "sless.kube5s.ru/service-finalizer"
// Reconcile — главный цикл управления Service.
// Pending → Building (запуск kaniko) → Ready (Deployment+Service+Ingress созданы, URL в Status)
func (r *ServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
svc := &slessv1alpha1.Service{}
if err := r.Get(ctx, req.NamespacedName, svc); err != nil {
if errors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, fmt.Errorf("get service: %w", err)
}
if !svc.DeletionTimestamp.IsZero() {
return r.handleServiceDeletion(ctx, svc)
}
if !containsString(svc.Finalizers, serviceFinalizerName) {
svc.Finalizers = append(svc.Finalizers, serviceFinalizerName)
if err := r.Update(ctx, svc); err != nil {
return ctrl.Result{}, fmt.Errorf("add service finalizer: %w", err)
}
return ctrl.Result{Requeue: true}, nil
}
// Идентичная логике FunctionReconciler: если s3Key изменился — пересобираем образ.
builtKey := svc.Annotations["sless.kube5s.ru/last-built-s3key"]
needsBuild := svc.Spec.S3Key != "" && builtKey != svc.Spec.S3Key
if needsBuild && svc.Status.Phase != slessv1alpha1.ServicePhaseBuilding {
logger.Info("starting service build", "service", svc.Name)
return r.startServiceBuild(ctx, svc)
}
switch svc.Status.Phase {
case slessv1alpha1.ServicePhaseBuilding:
return r.checkServiceBuild(ctx, svc)
case slessv1alpha1.ServicePhaseReady:
return r.ensureServiceDeployment(ctx, svc)
}
return ctrl.Result{}, nil
}
// startServiceBuild проверяет наличие образа в registry и либо пропускает сборку,
// либо запускает kaniko Job. Идемпотентность: если код не менялся (тег = hash s3Key),
// образ уже в registry → deploy без пересборки.
func (r *ServiceReconciler) startServiceBuild(ctx context.Context, svc *slessv1alpha1.Service) (ctrl.Result, error) {
imageRef := r.Builder.ImageRef(svc.Namespace, svc.Name, svc.Spec.S3Key)
// Проверяем: образ с этим тегом уже существует в registry?
// Если да — пропускаем kaniko, сразу переходим в Ready с известным imageRef.
// Если registry недоступен — requeue, не запускаем сборку (kaniko тоже упадёт).
exists, err := r.Builder.ImageExists(ctx, imageRef)
if err != nil {
return ctrl.Result{RequeueAfter: 10 * time.Second}, fmt.Errorf("check image exists: %w", err)
}
if exists {
logger := log.FromContext(ctx)
logger.Info("image already exists in registry, skipping build", "imageRef", imageRef)
if svc.Annotations == nil {
svc.Annotations = map[string]string{}
}
svc.Annotations["sless.kube5s.ru/last-built-s3key"] = svc.Spec.S3Key
if err := r.Update(ctx, svc); err != nil {
return ctrl.Result{}, fmt.Errorf("update service annotations (cache hit): %w", err)
}
svc.Status.Phase = slessv1alpha1.ServicePhaseReady
svc.Status.ImageRef = imageRef
svc.Status.Message = "Image restored from registry cache"
if err := r.Status().Update(ctx, svc); err != nil {
return ctrl.Result{}, fmt.Errorf("update service status (cache hit): %w", err)
}
return ctrl.Result{}, nil
}
jobName, err := r.Builder.Build(ctx, svc.Namespace, svc.Name, svc.Spec.S3Key)
if err != nil {
return r.setServiceFailed(ctx, svc, fmt.Sprintf("failed to start build: %v", err))
}
if svc.Annotations == nil {
svc.Annotations = map[string]string{}
}
svc.Annotations["sless.kube5s.ru/build-job"] = jobName
svc.Annotations["sless.kube5s.ru/last-built-s3key"] = svc.Spec.S3Key
if err := r.Update(ctx, svc); err != nil {
return ctrl.Result{}, fmt.Errorf("update service build annotations: %w", err)
}
svc.Status.Phase = slessv1alpha1.ServicePhaseBuilding
svc.Status.Message = "Building image: " + jobName
if err := r.Status().Update(ctx, svc); err != nil {
return ctrl.Result{}, fmt.Errorf("update service status to building: %w", err)
}
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
// checkServiceBuild проверяет статус kaniko Job.
func (r *ServiceReconciler) checkServiceBuild(ctx context.Context, svc *slessv1alpha1.Service) (ctrl.Result, error) {
jobName := svc.Annotations["sless.kube5s.ru/build-job"]
if jobName == "" {
svc.Status.Phase = slessv1alpha1.ServicePhasePending
_ = r.Status().Update(ctx, svc)
return ctrl.Result{Requeue: true}, nil
}
status, err := r.Builder.JobStatus(ctx, jobName)
if err != nil {
return ctrl.Result{}, fmt.Errorf("check service build job: %w", err)
}
switch status {
case "running":
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
case "succeeded":
imageRef := r.Builder.ImageRef(svc.Namespace, svc.Name, svc.Spec.S3Key)
svc.Status.Phase = slessv1alpha1.ServicePhaseReady
svc.Status.ImageRef = imageRef
svc.Status.Message = ""
now := metav1.Now()
svc.Status.LastBuiltAt = &now
if err := r.Status().Update(ctx, svc); err != nil {
return ctrl.Result{}, fmt.Errorf("update service status to ready: %w", err)
}
_ = r.Builder.Cleanup(ctx, jobName)
return ctrl.Result{Requeue: true}, nil
case "failed":
logs := getServiceBuildPodLogs(ctx, r.KubeClient, r.OperatorNamespace, jobName)
msg := "build job failed"
if logs != "" {
msg = "build job failed:\n" + logs
}
return r.setServiceFailed(ctx, svc, msg)
}
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
// ensureServiceDeployment создаёт или обновляет Deployment + k8s Service + Ingress.
// URL записывается в svc.Status.URL — доступен сразу без отдельного sless_trigger.
func (r *ServiceReconciler) ensureServiceDeployment(ctx context.Context, svc *slessv1alpha1.Service) (ctrl.Result, error) {
deployNS := "sless-fn-" + svc.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 service namespace: %w", err)
}
if r.HarborClient != nil {
if err := r.HarborClient.EnsureProject(ctx, svc.Namespace); err != nil {
log.FromContext(ctx).Error(err, "harbor ensure project", "project", svc.Namespace)
}
}
} else {
return ctrl.Result{}, fmt.Errorf("get service namespace: %w", err)
}
}
// Копируем pull-секрет чтобы kubelet мог скачать образ из приватного registry
if r.RegistrySecret != "" && r.OperatorNamespace != "" {
if err := r.ensureServiceRegistrySecret(ctx, deployNS); err != nil {
log.FromContext(ctx).Error(err, "failed to ensure registry secret", "ns", deployNS)
}
}
// Deployment
desired := r.buildServiceDeployment(svc, deployNS)
existing := &appsv1.Deployment{}
err := r.Get(ctx, client.ObjectKey{Name: svc.Name, Namespace: deployNS}, existing)
if errors.IsNotFound(err) {
if err := r.Create(ctx, desired); err != nil {
return ctrl.Result{}, fmt.Errorf("create service deployment: %w", err)
}
} else if err != nil {
return ctrl.Result{}, fmt.Errorf("get service deployment: %w", err)
} else {
existing.Spec.Template.Spec.Containers[0].Image = svc.Status.ImageRef
existing.Spec.Template.Spec.Containers[0].Env = desired.Spec.Template.Spec.Containers[0].Env
// Обновляем ресурсы — иначе memory_mb из PUT не применяется к Deployment
existing.Spec.Template.Spec.Containers[0].Resources = desired.Spec.Template.Spec.Containers[0].Resources
existing.Spec.Template.Spec.ImagePullSecrets = desired.Spec.Template.Spec.ImagePullSecrets
if err := r.Update(ctx, existing); err != nil {
return ctrl.Result{}, fmt.Errorf("update service deployment: %w", err)
}
}
// k8s Service — направляет трафик к Deployment
wantSvc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: svc.Name,
Namespace: deployNS,
Labels: map[string]string{"managed-by": "sless"},
},
Spec: corev1.ServiceSpec{
Selector: map[string]string{"app": svc.Name},
Ports: []corev1.ServicePort{{Port: 8080, Protocol: corev1.ProtocolTCP}},
},
}
existingSvc := &corev1.Service{}
if err := r.Get(ctx, client.ObjectKey{Name: svc.Name, Namespace: deployNS}, existingSvc); err != nil {
if errors.IsNotFound(err) {
if err := r.Create(ctx, wantSvc); err != nil {
return ctrl.Result{}, fmt.Errorf("create k8s service: %w", err)
}
} else {
return ctrl.Result{}, fmt.Errorf("get k8s service: %w", err)
}
}
// URL и Ingress формируются либо через ExternalURL (прокси через API), либо через Ingress
var funcURL string
if r.ExternalURL != "" {
// ExternalURL/fn/{ns}/{name} — работает через sless-api прокси, без wildcard DNS
funcURL = fmt.Sprintf("%s/fn/%s/%s", r.ExternalURL, svc.Namespace, svc.Name)
} else {
// Fallback: Ingress с поддоменом (требует wildcard DNS *.IngressHost)
host := fmt.Sprintf("%s-%s.%s", svc.Name, svc.Namespace, r.IngressHost)
pathType := netv1.PathTypePrefix
wantIng := &netv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: svc.Name,
Namespace: deployNS,
Annotations: map[string]string{
"kubernetes.io/ingress.class": "nginx",
},
},
Spec: netv1.IngressSpec{
Rules: []netv1.IngressRule{{
Host: host,
IngressRuleValue: netv1.IngressRuleValue{
HTTP: &netv1.HTTPIngressRuleValue{
Paths: []netv1.HTTPIngressPath{{
Path: "/",
PathType: &pathType,
Backend: netv1.IngressBackend{
Service: &netv1.IngressServiceBackend{
Name: svc.Name,
Port: netv1.ServiceBackendPort{Number: 8080},
},
},
}},
},
},
}},
},
}
existingIng := &netv1.Ingress{}
if err := r.Get(ctx, client.ObjectKey{Name: svc.Name, Namespace: deployNS}, existingIng); err != nil {
if errors.IsNotFound(err) {
if err := r.Create(ctx, wantIng); err != nil {
return ctrl.Result{}, fmt.Errorf("create service ingress: %w", err)
}
} else {
return ctrl.Result{}, fmt.Errorf("get service ingress: %w", err)
}
}
funcURL = "https://" + host
}
// Записываем URL в Status если изменился
if svc.Status.URL != funcURL {
svc.Status.URL = funcURL
if err := r.Status().Update(ctx, svc); err != nil {
return ctrl.Result{}, fmt.Errorf("update service status url: %w", err)
}
}
// Периодический requeue — self-healing: если Deployment/Service/Ingress удалены вручную, контроллер их пересоздаст
return ctrl.Result{RequeueAfter: 60 * time.Second}, nil
}
// buildServiceDeployment формирует Deployment манифест.
func (r *ServiceReconciler) buildServiceDeployment(svc *slessv1alpha1.Service, namespace string) *appsv1.Deployment {
replicas := int32(1)
var envVars []corev1.EnvVar
// Сортируем ключи для стабильного порядка — нестабильный порядок env vars вызывает лишние rollout'ы
keys := make([]string, 0, len(svc.Spec.Env))
for k := range svc.Spec.Env {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
envVars = append(envVars, corev1.EnvVar{Name: k, Value: svc.Spec.Env[k]})
}
// SLESS_ENTRYPOINT обязан быть передан в pod — runtime server использует его для загрузки функции.
// Если не передать, server.py/nodejs/go server упадёт на fallback handler.handle и не заметит
// неверный entrypoint, что маскирует проблему конфигурации.
if svc.Spec.Entrypoint != "" {
envVars = append(envVars, corev1.EnvVar{Name: "SLESS_ENTRYPOINT", Value: svc.Spec.Entrypoint})
}
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: svc.Name,
Namespace: namespace,
Labels: map[string]string{"app": svc.Name, "managed-by": "sless"},
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": svc.Name}},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": svc.Name}},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: svc.Name,
Image: svc.Status.ImageRef,
Env: envVars,
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{
corev1.ResourceMemory: resource.MustParse(fmt.Sprintf("%dMi", svc.Spec.MemoryMB)),
},
},
}},
ImagePullSecrets: func() []corev1.LocalObjectReference {
if r.RegistrySecret != "" {
return []corev1.LocalObjectReference{{Name: r.RegistrySecret}}
}
return nil
}(),
},
},
},
}
}
// ensureServiceRegistrySecret копирует pull-секрет в namespace сервисов.
func (r *ServiceReconciler) ensureServiceRegistrySecret(ctx context.Context, targetNS string) error {
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)
}
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
}
// handleServiceDeletion удаляет Deployment, k8s Service, Ingress и убирает finalizer.
func (r *ServiceReconciler) handleServiceDeletion(ctx context.Context, svc *slessv1alpha1.Service) (ctrl.Result, error) {
deployNS := "sless-fn-" + svc.Namespace
dep := &appsv1.Deployment{}
if err := r.Get(ctx, client.ObjectKey{Name: svc.Name, Namespace: deployNS}, dep); err == nil {
_ = r.Delete(ctx, dep)
}
// Убиваем kaniko Job если сборка шла в момент удаления
if jobName := svc.Annotations["sless.kube5s.ru/build-job"]; jobName != "" {
_ = r.Builder.Cleanup(ctx, jobName)
}
k8sSvc := &corev1.Service{}
if err := r.Get(ctx, client.ObjectKey{Name: svc.Name, Namespace: deployNS}, k8sSvc); err == nil {
_ = r.Delete(ctx, k8sSvc)
}
ing := &netv1.Ingress{}
if err := r.Get(ctx, client.ObjectKey{Name: svc.Name, Namespace: deployNS}, ing); err == nil {
_ = r.Delete(ctx, ing)
}
svc.Finalizers = removeString(svc.Finalizers, serviceFinalizerName)
if err := r.Update(ctx, svc); err != nil {
return ctrl.Result{}, fmt.Errorf("remove service finalizer: %w", err)
}
return ctrl.Result{}, nil
}
// setServiceFailed переводит сервис в фазу Failed.
func (r *ServiceReconciler) setServiceFailed(ctx context.Context, svc *slessv1alpha1.Service, msg string) (ctrl.Result, error) {
svc.Status.Phase = slessv1alpha1.ServicePhaseFailed
svc.Status.Message = msg
if err := r.Status().Update(ctx, svc); err != nil {
return ctrl.Result{}, fmt.Errorf("update service status to failed: %w", err)
}
return ctrl.Result{}, nil
}
// getServiceBuildPodLogs читает логи kaniko пода (последние 50 строк).
// Идентична getBuildPodLogs из function_controller.go, но вынесена в service_controller
// чтобы не создавать shared-помощника ради двух вызовов.
func getServiceBuildPodLogs(ctx context.Context, kube kubernetes.Interface, namespace, jobName string) string {
if kube == nil {
return ""
}
pods, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: "job-name=" + jobName,
})
if err != nil || len(pods.Items) == 0 {
return ""
}
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{})
stream, err := req.Stream(ctx)
if err != nil {
return ""
}
defer stream.Close()
buf := new(bytes.Buffer)
_, _ = io.Copy(buf, stream)
raw := strings.TrimSpace(buf.String())
if raw == "" {
return ""
}
lines := strings.Split(raw, "\n")
if len(lines) > 50 {
lines = lines[len(lines)-50:]
}
return strings.Join(lines, "\n")
}
// SetupWithManager sets up the controller with the Manager.
func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&slessv1alpha1.Service{}).
Complete(r)
}