From 64a08db9742e72627996156059444b8bbe88d12f Mon Sep 17 00:00:00 2001 From: Pervakov Grigorii Date: Tue, 21 Jul 2026 18:26:33 +0000 Subject: [PATCH 1/2] chores: refactor replica state --- internal/controller/clickhouse/sync.go | 59 +++++++++++---------- internal/controller/clickhouse/sync_test.go | 18 +++++-- internal/controller/keeper/sync.go | 57 ++++++++++---------- internal/controller/keeper/sync_test.go | 19 ++++--- internal/controller/replicastate.go | 54 ++++++++++++------- internal/controller/resourcemanager.go | 26 +++------ internal/controller/statusmanager.go | 45 ++++++++++++++-- 7 files changed, 173 insertions(+), 105 deletions(-) diff --git a/internal/controller/clickhouse/sync.go b/internal/controller/clickhouse/sync.go index b46e0ebc..50299e41 100644 --- a/internal/controller/clickhouse/sync.go +++ b/internal/controller/clickhouse/sync.go @@ -39,7 +39,6 @@ type replicaState struct { chctrl.ReplicaState ReloadError error - Error bool } func (r replicaState) Ready() bool { @@ -51,7 +50,7 @@ func (r replicaState) Ready() bool { } func (r replicaState) HasDiff(rev chctrl.RevisionState) bool { - return rev.ReplicaHasDiff(r.ReplicaState) + return rev.ReplicaHasDiff(r.ReplicaResources) } func (r replicaState) UpdateStage(rev chctrl.RevisionState) chctrl.ReplicaUpdateStage { @@ -59,11 +58,11 @@ func (r replicaState) UpdateStage(rev chctrl.RevisionState) chctrl.ReplicaUpdate return chctrl.StageNotExists } - if r.Error { + if r.StartupError != nil { return chctrl.StageError } - if !r.Updated() { + if !r.StatefulSetUpdated() { return chctrl.StageUpdating } @@ -71,8 +70,12 @@ func (r replicaState) UpdateStage(rev chctrl.RevisionState) chctrl.ReplicaUpdate return chctrl.StageHasDiff } + if !r.Reloaded(rev.ReloadConfigRevision) && r.ReloadError == nil { + return chctrl.StageUpdating + } + // ReloadError is a retryable connectivity state, not a replica error: - if !r.Ready() || r.ReloadError != nil || !r.Reloaded(rev.ReloadConfigRevision) { + if !r.Ready() || r.ReloadError != nil { return chctrl.StageNotReadyUpToDate } @@ -461,19 +464,19 @@ func (r *clickhouseReconciler) reconcileActiveReplicaStatus(ctx context.Context, return v1.ClickHouseReplicaID{}, replicaState{}, fmt.Errorf("get replica ID from StatefulSet labels: %w", err) } - hasError, err := chctrl.CheckPodError(ctx, log, r.GetClient(), &sts) + pod, err := chctrl.GetReplicaPod(ctx, log, r.GetClient(), &sts) if err != nil { - log.Warn("failed to check replica pod error", "statefulset", sts.Name, "error", err) - - hasError = true + return id, replicaState{}, fmt.Errorf("get replica pod: %w", err) } + startupErr := chctrl.PodStartupError(pod) + var ( probe replicaProbe reloadErr error ) - if !hasError && sts.Status.ReadyReplicas > 0 && r.commander != nil { + if startupErr == nil && sts.Status.ReadyReplicas > 0 && r.commander != nil { ctx, cancel := context.WithTimeout(ctx, chctrl.LoadReplicaStateTimeout) defer cancel() @@ -498,20 +501,22 @@ func (r *clickhouseReconciler) reconcileActiveReplicaStatus(ctx context.Context, return id, replicaState{ ReloadError: reloadErr, - Error: hasError, replicaProbe: probe, ReplicaState: chctrl.ReplicaState{ - STS: &sts, - CFG: configMaps[id], - PVCs: pvcs, + ReplicaResources: chctrl.ReplicaResources{ + STS: &sts, + CFG: configMaps[id], + PVCs: pvcs, + }, + Pod: pod, + StartupError: startupErr, }, }, nil }) for id, res := range execResults { if res.Err != nil { - log.Info("failed to load replica state", "error", res.Err, "replica_id", id) - continue + return chctrl.StepResult{}, fmt.Errorf("load replica state %s: %w", id, res.Err) } if _, ok := r.ReplicaState[id]; !ok { @@ -542,7 +547,7 @@ func (r *clickhouseReconciler) reconcileWarnings(ctx context.Context, log ctrlut results := ctrlutil.ExecuteParallel(ids, func(id v1.ClickHouseReplicaID) (v1.ClickHouseReplicaID, struct{}, error) { replica := r.ReplicaState[id] - if replica.Error || replica.STS == nil || replica.STS.Status.ReadyReplicas == 0 { + if replica.StartupError != nil || replica.STS == nil || replica.STS.Status.ReadyReplicas == 0 { return id, struct{}{}, nil } @@ -652,7 +657,7 @@ func (r *clickhouseReconciler) reconcileClusterRevisions(ctx context.Context, lo id := v1.ClickHouseReplicaID{ShardID: shard, Index: index} replica := r.ReplicaState[id] - if replica.HasDiff(r.revs) || !replica.Updated() { + if replica.HasDiff(r.revs) || !replica.StatefulSetUpdated() { notUpdated = append(notUpdated, id.String()) } @@ -875,7 +880,7 @@ func (r *clickhouseReconciler) reconcileDatabaseSync(ctx context.Context, log ct } func (r *clickhouseReconciler) reconcileCleanUp(ctx context.Context, log ctrlutil.Logger) (chctrl.StepResult, error) { - var replicasToRemove = map[v1.ClickHouseReplicaID]chctrl.ReplicaState{} + var replicasToRemove = map[v1.ClickHouseReplicaID]chctrl.ReplicaResources{} configMaps, err := chctrl.ListReplicaResources[v1.ClickHouseReplicaID, *corev1.ConfigMap, *corev1.ConfigMapList](ctx, &r.ResourceManager, v1.ClickHouseIDFromLabels) if err != nil { @@ -887,7 +892,7 @@ func (r *clickhouseReconciler) reconcileCleanUp(ctx context.Context, log ctrluti continue } - replicasToRemove[id] = chctrl.ReplicaState{ + replicasToRemove[id] = chctrl.ReplicaResources{ CFG: configMap, } } @@ -941,8 +946,9 @@ func (r *clickhouseReconciler) reconcileCleanUp(ctx context.Context, log ctrluti func (r *clickhouseReconciler) evaluateReplicaConditions() { var ( - errorIDs, notReadyIDs []string - notReadyShards []int32 + startupErrors = map[string]string{} + notReadyIDs []string + notReadyShards []int32 ) for shard := range r.Cluster.Shards() { @@ -951,8 +957,8 @@ func (r *clickhouseReconciler) evaluateReplicaConditions() { id := v1.ClickHouseReplicaID{ShardID: shard, Index: index} replica := r.ReplicaState[id] - if replica.Error { - errorIDs = append(errorIDs, id.String()) + if replica.StartupError != nil { + startupErrors[id.String()] = *replica.StartupError } if !replica.Ready() { @@ -974,7 +980,7 @@ func (r *clickhouseReconciler) evaluateReplicaConditions() { exists := len(r.ReplicaState) expected := int(r.Cluster.Replicas() * r.Cluster.Shards()) - r.SetCondition(chctrl.ReplicaStartupCondition(errorIDs)) + r.SetCondition(chctrl.ReplicaStartupCondition(startupErrors)) r.SetCondition(chctrl.ClusterSizeCondition(exists, expected)) if r.commander == nil { @@ -1028,8 +1034,7 @@ func (r *clickhouseReconciler) updateReplica(ctx context.Context, log ctrlutil.L result, err := r.ReconcileReplicaResources(ctx, log, chctrl.ReplicaUpdateInput{ Revisions: r.revs, Existing: replica.ReplicaState, - HasError: replica.Error, - Desired: chctrl.ReplicaState{ + Desired: chctrl.ReplicaResources{ CFG: configMap, STS: statefulSet, PVCs: chctrl.DesiredPVCs(r.Cluster.Spec.DataVolumeClaimSpec, r.Cluster.Spec.AdditionalVolumeClaimTemplates), diff --git a/internal/controller/clickhouse/sync_test.go b/internal/controller/clickhouse/sync_test.go index 2ebc2885..95cc7abf 100644 --- a/internal/controller/clickhouse/sync_test.go +++ b/internal/controller/clickhouse/sync_test.go @@ -52,7 +52,9 @@ var _ = Describe("UpdateStage", func() { ReloadConfigRevision: rev.ReloadConfigRevision, UsersReloadConfigRevision: rev.ReloadConfigRevision, }, - ReplicaState: chctrl.ReplicaState{STS: sts, CFG: cfg}, + ReplicaState: chctrl.ReplicaState{ + ReplicaResources: chctrl.ReplicaResources{STS: sts, CFG: cfg}, + }, } } @@ -62,6 +64,8 @@ var _ = Describe("UpdateStage", func() { It("should treat a reload failure as NotReadyUpToDate, not Error", func() { replica := settledReplica() + replica.ReloadConfigRevision = "" + replica.UsersReloadConfigRevision = "" replica.ReloadError = errors.New("dial tcp: connect: connection refused") Expect(replica.UpdateStage(rev)).To(Equal(chctrl.StageNotReadyUpToDate)) @@ -76,10 +80,18 @@ var _ = Describe("UpdateStage", func() { Expect(replica.UpdateStage(rev)).To(Equal(chctrl.StageHasDiff)) }) - It("should report NotReadyUpToDate when the probe returned nothing", func() { + It("should report Updating while waiting for a reload result", func() { replica := settledReplica() replica.replicaProbe = replicaProbe{} - Expect(replica.UpdateStage(rev)).To(Equal(chctrl.StageNotReadyUpToDate)) + Expect(replica.UpdateStage(rev)).To(Equal(chctrl.StageUpdating)) + }) + + It("should wait for an existing StatefulSet update before a new diff", func() { + replica := settledReplica() + replica.STS.Status.ObservedGeneration = 0 + ctrlutil.AddSpecHashToObject(replica.STS, "next-sts-rev") + + Expect(replica.UpdateStage(rev)).To(Equal(chctrl.StageUpdating)) }) }) diff --git a/internal/controller/keeper/sync.go b/internal/controller/keeper/sync.go index 1fe606f9..89492846 100644 --- a/internal/controller/keeper/sync.go +++ b/internal/controller/keeper/sync.go @@ -27,7 +27,6 @@ import ( type replicaState struct { chctrl.ReplicaState - Error bool Status serverStatus } @@ -45,7 +44,7 @@ func (r replicaState) Ready(replicaCount int) bool { } func (r replicaState) HasDiff(rev chctrl.RevisionState) bool { - return rev.ReplicaHasDiff(r.ReplicaState) + return rev.ReplicaHasDiff(r.ReplicaResources) } func (r replicaState) UpdateStage(rev chctrl.RevisionState, replicaCount int) chctrl.ReplicaUpdateStage { @@ -53,11 +52,11 @@ func (r replicaState) UpdateStage(rev chctrl.RevisionState, replicaCount int) ch return chctrl.StageNotExists } - if r.Error { + if r.StartupError != nil { return chctrl.StageError } - if !r.Updated() { + if !r.StatefulSetUpdated() { return chctrl.StageUpdating } @@ -226,15 +225,15 @@ func (r *keeperReconciler) reconcileActiveReplicaStatus(ctx context.Context, log return -1, replicaState{}, fmt.Errorf("get StatefulSet replica ID: %w", err) } - hasError, err := chctrl.CheckPodError(ctx, log, r.GetClient(), &sts) + pod, err := chctrl.GetReplicaPod(ctx, log, r.GetClient(), &sts) if err != nil { - log.Warn("failed to check replica pod error", "statefulset", sts.Name, "error", err) - - hasError = true + return id, replicaState{}, fmt.Errorf("get replica pod: %w", err) } + startupErr := chctrl.PodStartupError(pod) + var status serverStatus - if !hasError && sts.Status.ReadyReplicas > 0 { + if startupErr == nil && sts.Status.ReadyReplicas > 0 { ctx, cancel := context.WithTimeout(ctx, chctrl.LoadReplicaStateTimeout) defer cancel() @@ -242,28 +241,30 @@ func (r *keeperReconciler) reconcileActiveReplicaStatus(ctx context.Context, log r.Cluster.Spec.Settings.TLS.Required) } - pvcs, pvcErr := r.GetReplicaPVCs(ctx, &sts) - if pvcErr != nil { - log.Error(pvcErr, "failed to get PVCs for replica", "replica_id", id) + pvcs, err := r.GetReplicaPVCs(ctx, &sts) + if err != nil { + log.Error(err, "failed to get PVCs for replica", "replica_id", id) } log.Debug("load replica state done", "replica_id", id, "statefulset", sts.Name) return id, replicaState{ - Error: hasError, Status: status, ReplicaState: chctrl.ReplicaState{ - STS: &sts, - CFG: configMaps[id], - PVCs: pvcs, + ReplicaResources: chctrl.ReplicaResources{ + STS: &sts, + CFG: configMaps[id], + PVCs: pvcs, + }, + Pod: pod, + StartupError: startupErr, }, }, nil }) for id, res := range execResults { if res.Err != nil { - log.Info("failed to load replica state", "error", res.Err, "replica_id", id) - continue + return chctrl.StepResult{}, fmt.Errorf("load replica state %d: %w", id, res.Err) } if _, ok := r.ReplicaState[id]; !ok { @@ -285,7 +286,6 @@ func (r *keeperReconciler) reconcileActiveReplicaStatus(ctx context.Context, log if _, exists := r.ReplicaState[id]; !exists { log.Info("adding missing replica from quorum config", "replica_id", id) r.ReplicaState[id] = replicaState{ - Error: false, Status: serverStatus{}, } } @@ -556,16 +556,18 @@ func (r *keeperReconciler) reconcileCleanUp(ctx context.Context, log ctrlutil.Lo } func (r *keeperReconciler) evaluateReplicaConditions() { - var errorIDs, notReadyIDs, notUpdatedIDs []string - - replicasByMode := map[string][]v1.KeeperReplicaID{} + var ( + notReadyIDs, notUpdatedIDs []string + startupErrors = map[string]string{} + replicasByMode = map[string][]v1.KeeperReplicaID{} + ) r.Cluster.Status.ReadyReplicas = 0 for id, replica := range r.ReplicaState { idStr := fmt.Sprintf("%d", id) - if replica.Error { - errorIDs = append(errorIDs, idStr) + if replica.StartupError != nil { + startupErrors[idStr] = *replica.StartupError } if !replica.Ready(len(r.ReplicaState)) { @@ -575,12 +577,12 @@ func (r *keeperReconciler) evaluateReplicaConditions() { replicasByMode[replica.Status.ServerState] = append(replicasByMode[replica.Status.ServerState], id) } - if replica.HasDiff(r.revs) || !replica.Updated() { + if replica.HasDiff(r.revs) || !replica.StatefulSetUpdated() { notUpdatedIDs = append(notUpdatedIDs, idStr) } } - r.SetCondition(chctrl.ReplicaStartupCondition(errorIDs)) + r.SetCondition(chctrl.ReplicaStartupCondition(startupErrors)) r.SetCondition(chctrl.HealthyCondition(notReadyIDs)) r.SetCondition(chctrl.ConfigSyncCondition(nil, notUpdatedIDs, nil)) r.evaluateVersionConditions(len(notUpdatedIDs) > 0) @@ -731,8 +733,7 @@ func (r *keeperReconciler) updateReplica(ctx context.Context, log ctrlutil.Logge result, err := r.ReconcileReplicaResources(ctx, log, chctrl.ReplicaUpdateInput{ Revisions: r.revs, Existing: replica.ReplicaState, - HasError: replica.Error, - Desired: chctrl.ReplicaState{ + Desired: chctrl.ReplicaResources{ CFG: configMap, STS: statefulSet, PVCs: chctrl.DesiredPVCs(r.Cluster.Spec.DataVolumeClaimSpec, nil), diff --git a/internal/controller/keeper/sync_test.go b/internal/controller/keeper/sync_test.go index 0106e641..4714d23c 100644 --- a/internal/controller/keeper/sync_test.go +++ b/internal/controller/keeper/sync_test.go @@ -79,13 +79,14 @@ var _ = Describe("UpdateReplica", Ordered, func() { sts.Status.ObservedGeneration = sts.Generation sts.Status.ReadyReplicas = 1 rec.ReplicaState[replicaID] = replicaState{ - Error: false, Status: serverStatus{ ServerState: ModeStandalone, }, ReplicaState: controller.ReplicaState{ - STS: sts, - CFG: mustGet[*corev1.ConfigMap](ctx, rec.GetClient(), cfgKey), + ReplicaResources: controller.ReplicaResources{ + STS: sts, + CFG: mustGet[*corev1.ConfigMap](ctx, rec.GetClient(), cfgKey), + }, }, } result, err := rec.reconcileReplicaResources(ctx, log) @@ -112,11 +113,12 @@ var _ = Describe("UpdateReplica", Ordered, func() { Expect(previousHash).ToNot(BeEmpty()) rec.ReplicaState[replicaID] = replicaState{ - Error: false, Status: serverStatus{ ServerState: ModeStandalone, }, - ReplicaState: controller.ReplicaState{STS: sts}, + ReplicaState: controller.ReplicaState{ + ReplicaResources: controller.ReplicaResources{STS: sts}, + }, } rec.Cluster.Spec.Settings.Logger.Level = "info" rec.revs.ConfigurationRevision = "cfg-v2" @@ -163,8 +165,11 @@ var _ = Describe("UpdateReplica", Ordered, func() { By("setting replica state with error and a spec diff") rec.ReplicaState[replicaID] = replicaState{ - Error: true, - ReplicaState: controller.ReplicaState{STS: sts}, + ReplicaState: controller.ReplicaState{ + ReplicaResources: controller.ReplicaResources{STS: sts}, + Pod: pod, + StartupError: new("CreateContainerConfigError"), + }, } result, err := rec.reconcileReplicaResources(ctx, log) diff --git a/internal/controller/replicastate.go b/internal/controller/replicastate.go index ab099364..a18f7d70 100644 --- a/internal/controller/replicastate.go +++ b/internal/controller/replicastate.go @@ -54,15 +54,15 @@ type RevisionState struct { PVCRevisions map[string]string } -// ReplicaState holds resources owned by a single replica. -type ReplicaState struct { +// ReplicaResources holds resources owned by a single replica. +type ReplicaResources struct { STS *appsv1.StatefulSet CFG *corev1.ConfigMap PVCs map[string]*corev1.PersistentVolumeClaim } -// Updated checks whether StatefulSet controller applied updates. -func (s ReplicaState) Updated() bool { +// StatefulSetUpdated checks whether StatefulSet controller applied updates. +func (s ReplicaResources) StatefulSetUpdated() bool { if s.STS == nil { return false } @@ -72,7 +72,7 @@ func (s ReplicaState) Updated() bool { } // ReplicaHasDiff checks whether any replica resources should be updated. -func (rev RevisionState) ReplicaHasDiff(state ReplicaState) bool { +func (rev RevisionState) ReplicaHasDiff(state ReplicaResources) bool { if state.STS == nil { return true } @@ -107,36 +107,52 @@ func (rev RevisionState) ReplicaHasDiff(state ReplicaState) bool { return false } -var podErrorStatuses = []string{"ImagePullBackOff", "ErrImagePull", "CrashLoopBackOff", "CreateContainerError", "CreateContainerConfigError", "InvalidImageName"} +// ReplicaState holds the observed state of a single replica. +type ReplicaState struct { + ReplicaResources + + Pod *corev1.Pod + StartupError *string +} -// CheckPodError checks if the pod of the given StatefulSet have permanent errors preventing it from starting. -func CheckPodError(ctx context.Context, log util.Logger, client client.Client, sts *appsv1.StatefulSet) (bool, error) { - var pod corev1.Pod +// GetReplicaPod loads the Pod of the given StatefulSet. +func GetReplicaPod(ctx context.Context, log util.Logger, client client.Client, sts *appsv1.StatefulSet) (*corev1.Pod, error) { + pod := &corev1.Pod{} podName := sts.Name + "-0" if err := client.Get(ctx, types.NamespacedName{ Namespace: sts.Namespace, Name: podName, - }, &pod); err != nil { + }, pod); err != nil { if !k8serrors.IsNotFound(err) { - return false, fmt.Errorf("get clickhouse pod %q: %w", podName, err) + return nil, fmt.Errorf("get pod %q: %w", podName, err) } log.Info("pod does not exist", "pod", podName, "statefulset", sts.Name) - return false, nil + return nil, nil } - isError := false - for _, status := range pod.Status.ContainerStatuses { - if status.State.Waiting != nil && slices.Contains(podErrorStatuses, status.State.Waiting.Reason) { - log.Info("pod in error state", "pod", podName, "reason", status.State.Waiting.Reason) + return pod, nil +} + +var podErrorStatuses = []string{"ImagePullBackOff", "ErrImagePull", "CrashLoopBackOff", "CreateContainerError", "CreateContainerConfigError", "InvalidImageName"} + +// PodStartupError reports a non-empty description if Pod experience startup errors. +func PodStartupError(pod *corev1.Pod) *string { + if pod == nil { + return nil + } - isError = true - break + for _, statuses := range [][]corev1.ContainerStatus{pod.Status.InitContainerStatuses, pod.Status.ContainerStatuses} { + for _, status := range statuses { + waiting := status.State.Waiting + if waiting != nil && slices.Contains(podErrorStatuses, waiting.Reason) { + return new(fmt.Sprintf("container %q: %s: %s", status.Name, waiting.Reason, waiting.Message)) + } } } - return isError, nil + return nil } diff --git a/internal/controller/resourcemanager.go b/internal/controller/resourcemanager.go index d0981d10..feaada36 100644 --- a/internal/controller/resourcemanager.go +++ b/internal/controller/resourcemanager.go @@ -215,8 +215,7 @@ func (rm *ResourceManager) Delete(ctx context.Context, resource client.Object, a type ReplicaUpdateInput struct { Revisions RevisionState Existing ReplicaState - Desired ReplicaState - HasError bool + Desired ReplicaResources } // ReconcileReplicaResources reconciles a replica's ConfigMap, StatefulSet and PVC. @@ -297,27 +296,18 @@ func (rm *ResourceManager) ReconcileReplicaResources( return &ctrlruntime.Result{RequeueAfter: RequeueProbePoll}, nil } - // Delete stuck pod if in error state so the StatefulSet controller can recreate it - if input.HasError { - podName := input.Existing.STS.Name + "-0" - pod := &corev1.Pod{} - - err = rm.ctrl.GetClient().Get(ctx, types.NamespacedName{Namespace: input.Existing.STS.Namespace, Name: podName}, pod) - if err != nil { - if k8serrors.IsNotFound(err) { - return &ctrlruntime.Result{RequeueAfter: RequeueProbePoll}, nil - } - - log.Warn("failed to get error pod", "pod", podName, "error", err) - + // Delete stuck pod if in error state so the StatefulSet controller can recreate it. + if input.Existing.StartupError != nil { + pod := input.Existing.Pod + if pod == nil { return &ctrlruntime.Result{RequeueAfter: RequeueProbePoll}, nil } if pod.Labels[appsv1.ControllerRevisionHashLabelKey] != input.Existing.STS.Status.UpdateRevision { - log.Info("deleting pod stuck in error state", "pod", podName) + log.Info("deleting pod stuck in error state", "pod", pod.Name) - if err = rm.ctrl.GetClient().Delete(ctx, pod); err != nil { - log.Warn("failed to delete stuck pod", "pod", podName, "error", err) + if err = rm.Delete(ctx, pod, v1.EventActionReconciling, client.Preconditions{UID: &pod.UID}); err != nil { + log.Warn("failed to delete stuck pod", "pod", pod.Name, "error", err) } return &ctrlruntime.Result{RequeueAfter: RequeueProbePoll}, nil diff --git a/internal/controller/statusmanager.go b/internal/controller/statusmanager.go index f6b8253a..387d72d5 100644 --- a/internal/controller/statusmanager.go +++ b/internal/controller/statusmanager.go @@ -3,8 +3,11 @@ package controller import ( "context" "fmt" + "maps" "slices" + "strings" "time" + "unicode/utf8" gcmp "github.com/google/go-cmp/cmp" "k8s.io/apimachinery/pkg/api/meta" @@ -17,6 +20,8 @@ import ( util "github.com/ClickHouse/clickhouse-operator/internal/controllerutil" ) +const maxConditionMessageLength = 32 * 1024 + // clusterStatus constrains pointer-to-status types (*ClickHouseClusterStatus, *KeeperClusterStatus). type clusterStatus[S any] interface { *S @@ -208,9 +213,28 @@ func replicaCondition(condType v1.ConditionType, ids []string, falseReason, true } // ReplicaStartupCondition evaluates ReplicaStartupSucceeded. -func ReplicaStartupCondition(errorIDs []string) metav1.Condition { - return replicaCondition(v1.ConditionTypeReplicaStartupSucceeded, errorIDs, - v1.ConditionReasonReplicaError, v1.ConditionReasonReplicasRunning, "Replicas have startup errors") +func ReplicaStartupCondition(startupErrors map[string]string) metav1.Condition { + if len(startupErrors) == 0 { + return metav1.Condition{ + Type: v1.ConditionTypeReplicaStartupSucceeded, + Status: metav1.ConditionTrue, + Reason: v1.ConditionReasonReplicasRunning, + } + } + + details := make([]string, 0, len(startupErrors)) + for _, id := range slices.Sorted(maps.Keys(startupErrors)) { + details = append(details, fmt.Sprintf("%s: %v", id, startupErrors[id])) + } + + message := fmt.Sprintf("Replicas have startup errors: [%s]", strings.Join(details, " ")) + + return metav1.Condition{ + Type: v1.ConditionTypeReplicaStartupSucceeded, + Status: metav1.ConditionFalse, + Reason: v1.ConditionReasonReplicaError, + Message: truncateString(message, maxConditionMessageLength), + } } // HealthyCondition evaluates Healthy. @@ -242,3 +266,18 @@ func ClusterSizeCondition(existing, expected int) metav1.Condition { return metav1.Condition{Type: v1.ConditionTypeClusterSizeAligned, Status: metav1.ConditionTrue, Reason: v1.ConditionReasonUpToDate} } } + +func truncateString(value string, maxLength int) string { + const suffix = "..." + + if len(value) <= maxLength { + return value + } + + value = value[:maxLength-len(suffix)] + for !utf8.ValidString(value) { + value = value[:len(value)-1] + } + + return value + suffix +} From 1a23f9d8ffdfa86750597581766caf4eb932c12e Mon Sep 17 00:00:00 2001 From: Pervakov Grigorii Date: Wed, 22 Jul 2026 13:38:24 +0200 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- internal/controller/replicastate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/controller/replicastate.go b/internal/controller/replicastate.go index a18f7d70..b084a275 100644 --- a/internal/controller/replicastate.go +++ b/internal/controller/replicastate.go @@ -139,7 +139,7 @@ func GetReplicaPod(ctx context.Context, log util.Logger, client client.Client, s var podErrorStatuses = []string{"ImagePullBackOff", "ErrImagePull", "CrashLoopBackOff", "CreateContainerError", "CreateContainerConfigError", "InvalidImageName"} -// PodStartupError reports a non-empty description if Pod experience startup errors. +// PodStartupError reports a non-empty description if Pod experiences startup errors. func PodStartupError(pod *corev1.Pod) *string { if pod == nil { return nil