diff --git a/controllers/function_controller.go b/controllers/function_controller.go index ea12a3b..ed35861 100644 --- a/controllers/function_controller.go +++ b/controllers/function_controller.go @@ -69,24 +69,23 @@ func (r *FunctionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c return ctrl.Result{Requeue: true}, nil } - // Определяем что делать в зависимости от текущей фазы - switch fn.Status.Phase { - case "", slessv1alpha1.FunctionPhasePending: - // Новая функция или сброшена в Pending — запускаем сборку образа + // Есть новый код (s3Key изменился по сравнению с последним запуском сборки) + // и сборка сейчас не идёт — запускаем. Это единственное место где решается "нужна ли сборка". + // Почему аннотация а не phase: phase может быть Failed/Ready от прошлого кода; + // новый upload меняет Spec.S3Key → контроллер сам понимает что нужно пересобрать. + builtKey := fn.Annotations["sless.kube5s.ru/last-built-s3key"] + needsBuild := fn.Spec.S3Key != "" && builtKey != fn.Spec.S3Key + + if needsBuild && fn.Status.Phase != slessv1alpha1.FunctionPhaseBuilding { logger.Info("starting build", "function", fn.Name) return r.startBuild(ctx, fn) + } + switch fn.Status.Phase { 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 @@ -94,29 +93,35 @@ func (r *FunctionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c const finalizerName = "sless.kube5s.ru/finalizer" -// startBuild переводит функцию в фазу Building и запускает kaniko Job. +// startBuild запускает kaniko Job и помечает функцию как Building. +// Критически важно: СНАЧАЛА сохраняем last-built-s3key аннотацию, ПОТОМ status. +// Это предотвращает повторный запуск сборки при параллельных reconcile — +// следующий reconcile увидит last-built-s3key == spec.S3Key и не войдёт в startBuild. 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 + // Обновляем аннотации ПЕРВЫМИ — это idempotency guard. + // Как только last-built-s3key == spec.S3Key, дальнейшие reconcile не будут + // вызывать startBuild снова, даже если status ещё не обновился. if fn.Annotations == nil { fn.Annotations = map[string]string{} } fn.Annotations["sless.kube5s.ru/build-job"] = jobName + fn.Annotations["sless.kube5s.ru/last-built-s3key"] = fn.Spec.S3Key + if err := r.Update(ctx, fn); err != nil { + return ctrl.Result{}, fmt.Errorf("update build annotations: %w", err) + } + // Обновляем статус после аннотаций 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 } diff --git a/internal/api/handler/upload.go b/internal/api/handler/upload.go index 193907b..64520cd 100644 --- a/internal/api/handler/upload.go +++ b/internal/api/handler/upload.go @@ -175,14 +175,6 @@ func (h *Handler) UploadCode(w http.ResponseWriter, r *http.Request) { return } - // Сбрасываем phase в Pending чтобы контроллер начал сборку - fn.Status.Phase = slessv1alpha1.FunctionPhasePending - fn.Status.Message = "code uploaded, build queued" - if err := h.K8s.Status().Update(r.Context(), fn); err != nil { - // Не фатально — контроллер подберёт через reconcile - h.Log.Warn("failed to reset function phase", "name", name, "ns", ns, "err", err) - } - writeJSON(w, http.StatusOK, map[string]string{ "s3_key": s3Key, "phase": string(slessv1alpha1.FunctionPhasePending), diff --git a/internal/builder/builder.go b/internal/builder/builder.go index 7d6a48f..f643d86 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -121,6 +121,11 @@ func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string) } if err := b.client.Create(ctx, job); err != nil { + // Если job с таким именем уже существует (два reconcile в одну секунду) — + // возвращаем имя, контроллер будет следить за ним как обычно + if errors.IsAlreadyExists(err) { + return jobName, nil + } return "", fmt.Errorf("create build job: %w", err) } return jobName, nil