Files
2026-07-13 12:24:33 +08:00

177 lines
5.9 KiB
Go

/*
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.
*/
package utils
import (
"context"
"fmt"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/wait"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// WaitDaemonSetPodReady waits until at least one pod selected by the
// DaemonSet's pod selector reports a Ready=True condition. Returns the
// name of the first Ready pod so the caller can target it with
// port-forward (kubectl port-forward needs a stable pod name; selecting
// the DaemonSet directly is flaky when multiple pods exist on a
// multi-node cluster). The DaemonSet's TCP readiness probe binds the
// LMCache server, so once the pod is Ready the HTTP frontend is also
// (very nearly) up — the caller should still retry HTTP calls briefly.
func WaitDaemonSetPodReady(
ctx context.Context,
c client.Client,
key types.NamespacedName,
timeout time.Duration,
) (string, error) {
var podName string
err := wait.PollUntilContextTimeout(ctx, 2*time.Second, timeout, true, func(ctx context.Context) (bool, error) {
ds := &appsv1.DaemonSet{}
if err := c.Get(ctx, key, ds); err != nil {
if apierrors.IsNotFound(err) {
return false, nil
}
return false, err
}
pods := &corev1.PodList{}
if err := c.List(ctx, pods,
client.InNamespace(key.Namespace),
client.MatchingLabels(ds.Spec.Selector.MatchLabels),
); err != nil {
return false, err
}
for i := range pods.Items {
p := &pods.Items[i]
if p.DeletionTimestamp != nil {
continue
}
if p.Status.Phase != corev1.PodRunning {
continue
}
for _, cond := range p.Status.Conditions {
if cond.Type == corev1.PodReady && cond.Status == corev1.ConditionTrue {
podName = p.Name
return true, nil
}
}
}
return false, nil
})
if err != nil {
return "", fmt.Errorf("wait DaemonSet %s pod Ready: %w", key, err)
}
return podName, nil
}
// WaitDeploymentAvailable polls a Deployment until its Available
// condition is True. Used to gate test logic on vLLM (or any other
// auxiliary workload) being fully scheduled and accepting traffic.
// We intentionally key off the Available condition rather than
// ReadyReplicas because Available encodes the rolling-update completion
// semantics — ReadyReplicas can flap during a fresh rollout even on a
// clean cluster.
func WaitDeploymentAvailable(
ctx context.Context,
c client.Client,
key types.NamespacedName,
timeout time.Duration,
) error {
return wait.PollUntilContextTimeout(ctx, 2*time.Second, timeout, true, func(ctx context.Context) (bool, error) {
dep := &appsv1.Deployment{}
if err := c.Get(ctx, key, dep); err != nil {
if apierrors.IsNotFound(err) {
return false, nil
}
return false, err
}
for _, cond := range dep.Status.Conditions {
if cond.Type == appsv1.DeploymentAvailable && cond.Status == corev1.ConditionTrue {
return true, nil
}
}
return false, nil
})
}
// WaitDeploymentAvailableOrImagePullError is WaitDeploymentAvailable with a
// fast-fail short-circuit: it returns a descriptive error the moment any pod
// selected by the Deployment has a container or init container wedged in
// ImagePullBackOff. A wrong image/tag, a missing pull Secret, or a credential
// without pull access never resolves on its own, so blocking for the full
// timeout only delays a guaranteed failure (and buries the registry's own
// error message, which ImagePullBackOff's waiting state carries verbatim).
// Returns nil once Available, the pull error when a pull is wedged, or the
// poll timeout error otherwise.
func WaitDeploymentAvailableOrImagePullError(
ctx context.Context,
c client.Client,
key types.NamespacedName,
timeout time.Duration,
) error {
return wait.PollUntilContextTimeout(ctx, 2*time.Second, timeout, true, func(ctx context.Context) (bool, error) {
dep := &appsv1.Deployment{}
if err := c.Get(ctx, key, dep); err != nil {
if apierrors.IsNotFound(err) {
return false, nil
}
return false, err
}
for _, cond := range dep.Status.Conditions {
if cond.Type == appsv1.DeploymentAvailable && cond.Status == corev1.ConditionTrue {
return true, nil
}
}
if msg := imagePullBackOff(ctx, c, key.Namespace, dep.Spec.Selector.MatchLabels); msg != "" {
return false, fmt.Errorf(
"deployment %s will never become Available — image pull wedged: %s", key, msg)
}
return false, nil
})
}
// imagePullBackOff scans pods matching sel and returns a one-line description
// (pod, container, image, kubelet message) of the first container or init
// container in ImagePullBackOff, or "" if none. The kubelet message is the
// registry's own error (e.g. "pull access denied" / "manifest unknown"), which
// is what a caller needs to tell a credential problem from a wrong tag.
func imagePullBackOff(ctx context.Context, c client.Client, ns string, sel map[string]string) string {
pods := &corev1.PodList{}
if err := c.List(ctx, pods, client.InNamespace(ns), client.MatchingLabels(sel)); err != nil {
return ""
}
for i := range pods.Items {
p := &pods.Items[i]
if p.DeletionTimestamp != nil {
continue
}
statuses := append(append([]corev1.ContainerStatus{},
p.Status.InitContainerStatuses...), p.Status.ContainerStatuses...)
for _, cs := range statuses {
if w := cs.State.Waiting; w != nil && w.Reason == "ImagePullBackOff" {
return fmt.Sprintf("pod %s container %q image %q: %s",
p.Name, cs.Name, cs.Image, w.Message)
}
}
}
return ""
}