package k8score import ( "context" "errors" "fmt" "regexp" "strings" "time" "github.com/google/uuid" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" apivalidation "k8s.io/apimachinery/pkg/api/validation" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes" ) // NodeDebugPodResult contains the coordinates of a created debug pod. type NodeDebugPodResult struct { PodName string `json:"podName"` UID types.UID `json:"uid"` Namespace string `json:"namespace"` ContainerName string `json:"containerName"` NodeName string `json:"nodeName"` } var nodeNameSanitizer = regexp.MustCompile(`[^a-z0-9-]`) var collapseDashes = regexp.MustCompile(`-{2,}`) var trimDashes = regexp.MustCompile(`^-+|-+$`) // sanitizeNodeName replaces characters not allowed in pod names with dashes. func sanitizeNodeName(name string) string { s := nodeNameSanitizer.ReplaceAllString(name, "-") s = collapseDashes.ReplaceAllString(s, "-") s = trimDashes.ReplaceAllString(s, "") if len(s) > 40 { s = s[:40] } return s } // sanitizeLabelValue ensures a string is valid as a Kubernetes label value // (max 63 chars, alphanumeric start/end, interior allows [a-zA-Z0-9._-]). func sanitizeLabelValue(v string) string { s := nodeNameSanitizer.ReplaceAllString(v, "-") s = collapseDashes.ReplaceAllString(s, "-") s = trimDashes.ReplaceAllString(s, "") if len(s) > 63 { s = s[:63] } s = trimDashes.ReplaceAllString(s, "") return s } // CreateNodeDebugPod creates a privileged debug pod scheduled on the given node. // The pod runs with host PID/network/IPC and mounts the host root at /host. func CreateNodeDebugPod(ctx context.Context, client kubernetes.Interface, nodeName, image string) (*NodeDebugPodResult, error) { if client == nil { return nil, fmt.Errorf("kubernetes client not initialized") } if image == "" { image = DefaultDebugImage } containerName := "debug" sanitized := sanitizeNodeName(nodeName) labelValue := sanitizeLabelValue(nodeName) podName := fmt.Sprintf("radar-node-debug-%s-%d", sanitized, time.Now().Unix()) pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: podName, Namespace: "default", Labels: map[string]string{ "app.kubernetes.io/managed-by": "radar", "radarhq.io/debug-node": labelValue, }, }, Spec: corev1.PodSpec{ NodeName: nodeName, HostPID: true, HostNetwork: true, HostIPC: true, RestartPolicy: corev1.RestartPolicyNever, ActiveDeadlineSeconds: func() *int64 { d := int64(3600); return &d }(), Containers: []corev1.Container{{ Name: containerName, Image: image, Command: []string{"sleep", "infinity"}, Stdin: true, TTY: true, SecurityContext: &corev1.SecurityContext{ Privileged: func() *bool { b := true; return &b }(), }, VolumeMounts: []corev1.VolumeMount{{ Name: "host-root", MountPath: "/host", }}, }}, Volumes: []corev1.Volume{{ Name: "host-root", VolumeSource: corev1.VolumeSource{ HostPath: &corev1.HostPathVolumeSource{ Path: "/", }, }, }}, Tolerations: []corev1.Toleration{{ Operator: corev1.TolerationOpExists, }}, }, } created, err := client.CoreV1().Pods("default").Create(ctx, pod, metav1.CreateOptions{}) if err != nil { return nil, fmt.Errorf("failed to create debug pod on node %s: %w", nodeName, err) } return &NodeDebugPodResult{ PodName: created.Name, UID: created.UID, Namespace: created.Namespace, ContainerName: containerName, NodeName: nodeName, }, nil } // WaitForPodRunning polls until the named pod reaches Running phase or the timeout expires. func WaitForPodRunning(ctx context.Context, client kubernetes.Interface, namespace, podName string, timeout time.Duration) error { deadline := time.After(timeout) ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): return ctx.Err() case <-deadline: return fmt.Errorf("timed out waiting for pod %s/%s to reach Running state", namespace, podName) case <-ticker.C: pod, err := client.CoreV1().Pods(namespace).Get(ctx, podName, metav1.GetOptions{}) if err != nil { return fmt.Errorf("failed to get pod %s/%s: %w", namespace, podName, err) } if pod.Status.Phase == corev1.PodRunning { return nil } if pod.Status.Phase == corev1.PodFailed || pod.Status.Phase == corev1.PodSucceeded { return fmt.Errorf("pod %s/%s entered %s phase", namespace, podName, pod.Status.Phase) } } } } // DeleteNodeDebugPods is a node-wide maintenance operation, not terminal cleanup. // Interactive callers must use DeleteNodeDebugPod with the creation result. // It deletes all debug pods for the given node. Matches both // the current "radarhq.io/debug-node" label and the legacy "radar.skyhook.io/ // debug-node" label so a Radar binary upgraded mid-debug-session can still // GC in-flight privileged pods created by the prior version. // // TODO(2026-Q3): drop the legacy selector once the migration window is done // (paired with the Argo legacy annotations in pkg/gitops/operations.go). func DeleteNodeDebugPods(ctx context.Context, client kubernetes.Interface, nodeName string) error { if client == nil { return fmt.Errorf("kubernetes client not initialized") } gracePeriod := int64(0) selectors := []string{ fmt.Sprintf("radarhq.io/debug-node=%s", sanitizeLabelValue(nodeName)), fmt.Sprintf("radar.skyhook.io/debug-node=%s", sanitizeLabelValue(nodeName)), } var errs []error for _, sel := range selectors { if err := client.CoreV1().Pods("default").DeleteCollection(ctx, metav1.DeleteOptions{GracePeriodSeconds: &gracePeriod}, metav1.ListOptions{LabelSelector: sel}, ); err != nil { errs = append(errs, err) } } return errors.Join(errs...) } // ValidateNodeDebugPodIdentity allows callers to reject malformed identities before // accessing the cluster. Values are never normalized: cleanup must preserve the // exact identity returned by creation, including the API server's UUID-form UID. func ValidateNodeDebugPodIdentity(namespace, podName string, uid types.UID) error { if namespace == "" || podName == "" || uid == "" { return fmt.Errorf("debug pod namespace, name and UID are required") } if errs := apivalidation.ValidateNamespaceName(namespace, false); len(errs) > 0 { return fmt.Errorf("invalid debug pod namespace: %s", strings.Join(errs, "; ")) } if errs := apivalidation.NameIsDNSSubdomain(podName, false); len(errs) > 0 { return fmt.Errorf("invalid debug pod name: %s", strings.Join(errs, "; ")) } if len(uid) != 36 || uuid.Validate(string(uid)) != nil { return fmt.Errorf("invalid debug pod UID: must be a hyphenated UUID") } return nil } // DeleteNodeDebugPod deletes only the pod identified by namespace, name and UID. // The UID precondition protects replacement pods, including after a context switch. func DeleteNodeDebugPod(ctx context.Context, client kubernetes.Interface, namespace, podName string, uid types.UID) error { if err := ValidateNodeDebugPodIdentity(namespace, podName, uid); err != nil { return err } if client == nil { return fmt.Errorf("kubernetes client not initialized") } gracePeriod := int64(0) err := client.CoreV1().Pods(namespace).Delete(ctx, podName, metav1.DeleteOptions{ GracePeriodSeconds: &gracePeriod, Preconditions: &metav1.Preconditions{UID: &uid}, }) if apierrors.IsNotFound(err) { return nil } return err }