From 7d75fb0f7fb749aa3dc28fa7e06cc0be3172b8cc Mon Sep 17 00:00:00 2001 From: Sergey Kurbatov Date: Mon, 24 Aug 2026 17:56:09 +0300 Subject: [PATCH 1/5] [istio] Add interactive istioctl debug via d8 network istio Signed-off-by: Sergey Kurbatov --- internal/network/istio/debug.go | 396 +++++++++++++++++++++++++++ internal/network/istio/debug_test.go | 174 ++++++++++++ internal/network/istio/istio.go | 133 +++++++++ internal/network/network.go | 2 + 4 files changed, 705 insertions(+) create mode 100644 internal/network/istio/debug.go create mode 100644 internal/network/istio/debug_test.go create mode 100644 internal/network/istio/istio.go diff --git a/internal/network/istio/debug.go b/internal/network/istio/debug.go new file mode 100644 index 000000000..3bf2c904b --- /dev/null +++ b/internal/network/istio/debug.go @@ -0,0 +1,396 @@ +/* +Copyright 2026 Flant JSC + +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 istio + +import ( + "context" + "fmt" + "os" + "time" + + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/remotecommand" + "k8s.io/kubectl/pkg/util/term" + "k8s.io/utils/ptr" +) + +const ( + resourceName = "istioctl-debug" + containerName = "istioctl-debug" + debugCMNamespace = "d8-system" + debugCMName = "debug-container" + debugCMImageKey = "image" + podReadyTimeout = 2 * time.Minute + podPollInterval = time.Second + podDeleteTimeout = 15 * time.Second + podDeleteGraceSec = int64(1) +) + +// Options controls how the debug session is created. +type Options struct { + Namespace string + TargetNamespace string + Image string + Command []string +} + +// Run applies istioctl-debug RBAC, starts the debug pod, attaches to it, and +// deletes the pod when the session ends. +func Run(ctx context.Context, kube kubernetes.Interface, restConfig *rest.Config, opts Options) error { + image, err := resolveDebugImage(ctx, kube, opts.Image) + if err != nil { + return err + } + + if err := ensureRBAC(ctx, kube, opts.Namespace, opts.TargetNamespace); err != nil { + return err + } + + pod, err := createDebugPod(ctx, kube, opts.Namespace, image, opts.Command) + if err != nil { + return err + } + + defer func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), podDeleteTimeout) + defer cancel() + + if delErr := deletePod(cleanupCtx, kube, opts.Namespace, pod.Name); delErr != nil { + fmt.Fprintf(os.Stderr, "warning: failed to delete debug pod %s/%s: %v\n", opts.Namespace, pod.Name, delErr) + } + }() + + fmt.Fprintf(os.Stderr, "Waiting for pod %s/%s to be running...\n", opts.Namespace, pod.Name) + + if err := waitForPodRunning(ctx, kube, opts.Namespace, pod.Name); err != nil { + return fmt.Errorf("wait for debug pod: %w", err) + } + + fmt.Fprintf(os.Stderr, "Attached to %s/%s (image %s). RBAC target namespace: %s\n", + opts.Namespace, pod.Name, image, opts.TargetNamespace) + + return attachToPod(ctx, kube, restConfig, opts.Namespace, pod.Name) +} + +func resolveDebugImage(ctx context.Context, kube kubernetes.Interface, override string) (string, error) { + if override != "" { + return override, nil + } + + cm, err := kube.CoreV1().ConfigMaps(debugCMNamespace).Get(ctx, debugCMName, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("get debug image from ConfigMap %s/%s (pass --image to override): %w", debugCMNamespace, debugCMName, err) + } + + image := cm.Data[debugCMImageKey] + if image == "" { + return "", fmt.Errorf("ConfigMap %s/%s has no %q key; pass --image to override", debugCMNamespace, debugCMName, debugCMImageKey) + } + + return image, nil +} + +func ensureRBAC(ctx context.Context, kube kubernetes.Interface, debugNamespace, targetNamespace string) error { + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: debugNamespace, + }, + } + _, err := kube.CoreV1().ServiceAccounts(debugNamespace).Create(ctx, sa, metav1.CreateOptions{}) + if err != nil && !apierrors.IsAlreadyExists(err) { + return fmt.Errorf("create ServiceAccount %s/%s: %w", debugNamespace, resourceName, err) + } + + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: targetNamespace, + }, + Rules: istioctlDebugRules(), + } + if err := createOrUpdateRole(ctx, kube, role); err != nil { + return err + } + + binding := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: targetNamespace, + }, + Subjects: []rbacv1.Subject{{ + Kind: rbacv1.ServiceAccountKind, + Name: resourceName, + Namespace: debugNamespace, + }}, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "Role", + Name: resourceName, + }, + } + if err := createOrUpdateRoleBinding(ctx, kube, binding); err != nil { + return err + } + + return nil +} + +func istioctlDebugRules() []rbacv1.PolicyRule { + return []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"pods"}, + Verbs: []string{"get", "list"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"pods/portforward"}, + Verbs: []string{"create"}, + }, + } +} + +func createOrUpdateRole(ctx context.Context, kube kubernetes.Interface, role *rbacv1.Role) error { + existing, err := kube.RbacV1().Roles(role.Namespace).Get(ctx, role.Name, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + _, err = kube.RbacV1().Roles(role.Namespace).Create(ctx, role, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("create Role %s/%s: %w", role.Namespace, role.Name, err) + } + + return nil + } + + if err != nil { + return fmt.Errorf("get Role %s/%s: %w", role.Namespace, role.Name, err) + } + + existing.Rules = role.Rules + _, err = kube.RbacV1().Roles(role.Namespace).Update(ctx, existing, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("update Role %s/%s: %w", role.Namespace, role.Name, err) + } + + return nil +} + +func createOrUpdateRoleBinding(ctx context.Context, kube kubernetes.Interface, binding *rbacv1.RoleBinding) error { + existing, err := kube.RbacV1().RoleBindings(binding.Namespace).Get(ctx, binding.Name, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + _, err = kube.RbacV1().RoleBindings(binding.Namespace).Create(ctx, binding, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("create RoleBinding %s/%s: %w", binding.Namespace, binding.Name, err) + } + + return nil + } + + if err != nil { + return fmt.Errorf("get RoleBinding %s/%s: %w", binding.Namespace, binding.Name, err) + } + + existing.Subjects = binding.Subjects + existing.RoleRef = binding.RoleRef + _, err = kube.RbacV1().RoleBindings(binding.Namespace).Update(ctx, existing, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("update RoleBinding %s/%s: %w", binding.Namespace, binding.Name, err) + } + + return nil +} + +func buildDebugPod(namespace, image string, command []string) *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/name": resourceName, + "app.kubernetes.io/managed-by": "d8", + }, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: resourceName, + AutomountServiceAccountToken: ptr.To(true), + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{{ + Name: containerName, + Image: image, + ImagePullPolicy: corev1.PullIfNotPresent, + Command: command, + Stdin: true, + StdinOnce: true, + TTY: true, + }}, + }, + } +} + +func createDebugPod(ctx context.Context, kube kubernetes.Interface, namespace, image string, command []string) (*corev1.Pod, error) { + if existing, err := kube.CoreV1().Pods(namespace).Get(ctx, resourceName, metav1.GetOptions{}); err == nil { + fmt.Fprintf(os.Stderr, "Deleting leftover debug pod %s/%s\n", namespace, existing.Name) + if err := deletePod(ctx, kube, namespace, existing.Name); err != nil { + return nil, err + } + + if err := waitForPodGone(ctx, kube, namespace, existing.Name); err != nil { + return nil, err + } + } else if !apierrors.IsNotFound(err) { + return nil, fmt.Errorf("get debug pod %s/%s: %w", namespace, resourceName, err) + } + + pod, err := kube.CoreV1().Pods(namespace).Create(ctx, buildDebugPod(namespace, image, command), metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("create debug pod %s/%s: %w", namespace, resourceName, err) + } + + return pod, nil +} + +func deletePod(ctx context.Context, kube kubernetes.Interface, namespace, name string) error { + err := kube.CoreV1().Pods(namespace).Delete(ctx, name, metav1.DeleteOptions{ + GracePeriodSeconds: ptr.To(podDeleteGraceSec), + }) + if apierrors.IsNotFound(err) { + return nil + } + + return err +} + +func waitForPodGone(ctx context.Context, kube kubernetes.Interface, namespace, name string) error { + return wait.PollUntilContextTimeout(ctx, podPollInterval, podReadyTimeout, true, func(ctx context.Context) (bool, error) { + _, err := kube.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + return true, nil + } + + if err != nil { + return false, err + } + + return false, nil + }) +} + +func waitForPodRunning(ctx context.Context, kube kubernetes.Interface, namespace, name string) error { + return wait.PollUntilContextTimeout(ctx, podPollInterval, podReadyTimeout, true, func(ctx context.Context) (bool, error) { + pod, err := kube.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, err + } + + switch pod.Status.Phase { + case corev1.PodRunning: + return true, nil + case corev1.PodFailed, corev1.PodSucceeded: + return false, fmt.Errorf("pod %s/%s ended with phase %s: %s", namespace, name, pod.Status.Phase, podReason(pod)) + } + + if reason, msg, ok := terminalContainerWait(pod); ok { + return false, fmt.Errorf("pod %s/%s cannot start: %s: %s", namespace, name, reason, msg) + } + + return false, nil + }) +} + +func podReason(pod *corev1.Pod) string { + if pod.Status.Message != "" { + return pod.Status.Message + } + + if reason, msg, ok := terminalContainerWait(pod); ok { + if msg != "" { + return fmt.Sprintf("%s: %s", reason, msg) + } + + return reason + } + + return pod.Status.Reason +} + +func terminalContainerWait(pod *corev1.Pod) (string, string, bool) { + for _, cs := range pod.Status.ContainerStatuses { + if cs.State.Waiting == nil { + continue + } + + switch cs.State.Waiting.Reason { + case "ErrImagePull", "ImagePullBackOff", "CrashLoopBackOff", "CreateContainerConfigError", "InvalidImageName": + return cs.State.Waiting.Reason, cs.State.Waiting.Message, true + } + } + + return "", "", false +} + +func attachToPod(ctx context.Context, kube kubernetes.Interface, restConfig *rest.Config, namespace, name string) error { + req := kube.CoreV1().RESTClient().Post(). + Resource("pods"). + Name(name). + Namespace(namespace). + SubResource("attach"). + VersionedParams(&corev1.PodAttachOptions{ + Container: containerName, + Stdin: true, + Stdout: true, + Stderr: false, + TTY: true, + }, scheme.ParameterCodec) + + executor, err := remotecommand.NewSPDYExecutor(restConfig, "POST", req.URL()) + if err != nil { + return fmt.Errorf("create attach executor: %w", err) + } + + tty := term.TTY{ + In: os.Stdin, + Out: os.Stdout, + Raw: true, + } + + var sizeQueue remotecommand.TerminalSizeQueue + if tty.IsTerminalIn() { + sizeQueue = tty.MonitorSize(tty.GetSize()) + } + + return tty.Safe(func() error { + streamErr := executor.StreamWithContext(ctx, remotecommand.StreamOptions{ + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: nil, + Tty: true, + TerminalSizeQueue: sizeQueue, + }) + if streamErr != nil { + return fmt.Errorf("attach to pod %s/%s: %w", namespace, name, streamErr) + } + + return nil + }) +} diff --git a/internal/network/istio/debug_test.go b/internal/network/istio/debug_test.go new file mode 100644 index 000000000..ab07461b5 --- /dev/null +++ b/internal/network/istio/debug_test.go @@ -0,0 +1,174 @@ +/* +Copyright 2026 Flant JSC + +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 istio + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestEnsureRBACCreatesObjects(t *testing.T) { + kube := fake.NewSimpleClientset() + ctx := context.Background() + + require.NoError(t, ensureRBAC(ctx, kube, "debug-ns", "target-ns")) + + sa, err := kube.CoreV1().ServiceAccounts("debug-ns").Get(ctx, resourceName, metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "debug-ns", sa.Namespace) + + role, err := kube.RbacV1().Roles("target-ns").Get(ctx, resourceName, metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, istioctlDebugRules(), role.Rules) + + binding, err := kube.RbacV1().RoleBindings("target-ns").Get(ctx, resourceName, metav1.GetOptions{}) + require.NoError(t, err) + require.Len(t, binding.Subjects, 1) + assert.Equal(t, rbacv1.ServiceAccountKind, binding.Subjects[0].Kind) + assert.Equal(t, resourceName, binding.Subjects[0].Name) + assert.Equal(t, "debug-ns", binding.Subjects[0].Namespace) + assert.Equal(t, "Role", binding.RoleRef.Kind) + assert.Equal(t, resourceName, binding.RoleRef.Name) +} + +func TestEnsureRBACIsIdempotentAndUpdatesRules(t *testing.T) { + kube := fake.NewSimpleClientset() + ctx := context.Background() + + require.NoError(t, ensureRBAC(ctx, kube, "debug-ns", "target-ns")) + + role, err := kube.RbacV1().Roles("target-ns").Get(ctx, resourceName, metav1.GetOptions{}) + require.NoError(t, err) + role.Rules = nil + _, err = kube.RbacV1().Roles("target-ns").Update(ctx, role, metav1.UpdateOptions{}) + require.NoError(t, err) + + require.NoError(t, ensureRBAC(ctx, kube, "debug-ns", "target-ns")) + + role, err = kube.RbacV1().Roles("target-ns").Get(ctx, resourceName, metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, istioctlDebugRules(), role.Rules) +} + +func TestResolveDebugImage(t *testing.T) { + ctx := context.Background() + + t.Run("override wins", func(t *testing.T) { + kube := fake.NewSimpleClientset() + img, err := resolveDebugImage(ctx, kube, "override:tag") + require.NoError(t, err) + assert.Equal(t, "override:tag", img) + }) + + t.Run("from configmap", func(t *testing.T) { + kube := fake.NewSimpleClientset(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: debugCMName, Namespace: debugCMNamespace}, + Data: map[string]string{debugCMImageKey: "registry.example/debug:1"}, + }) + img, err := resolveDebugImage(ctx, kube, "") + require.NoError(t, err) + assert.Equal(t, "registry.example/debug:1", img) + }) + + t.Run("missing configmap", func(t *testing.T) { + kube := fake.NewSimpleClientset() + _, err := resolveDebugImage(ctx, kube, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "pass --image") + }) + + t.Run("empty image key", func(t *testing.T) { + kube := fake.NewSimpleClientset(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: debugCMName, Namespace: debugCMNamespace}, + Data: map[string]string{}, + }) + _, err := resolveDebugImage(ctx, kube, "") + require.Error(t, err) + assert.Contains(t, err.Error(), debugCMImageKey) + }) +} + +func TestBuildDebugPod(t *testing.T) { + pod := buildDebugPod("debug-ns", "img:tag", []string{"bash"}) + + assert.Equal(t, resourceName, pod.Name) + assert.Equal(t, "debug-ns", pod.Namespace) + assert.Equal(t, resourceName, pod.Spec.ServiceAccountName) + require.NotNil(t, pod.Spec.AutomountServiceAccountToken) + assert.True(t, *pod.Spec.AutomountServiceAccountToken) + assert.Equal(t, corev1.RestartPolicyNever, pod.Spec.RestartPolicy) + require.Len(t, pod.Spec.Containers, 1) + c := pod.Spec.Containers[0] + assert.Equal(t, "img:tag", c.Image) + assert.Equal(t, []string{"bash"}, c.Command) + assert.True(t, c.Stdin) + assert.True(t, c.StdinOnce) + assert.True(t, c.TTY) +} + +func TestCreateDebugPodReplacesLeftover(t *testing.T) { + ctx := context.Background() + leftover := buildDebugPod("debug-ns", "old:tag", []string{"bash"}) + kube := fake.NewSimpleClientset(leftover) + + pod, err := createDebugPod(ctx, kube, "debug-ns", "new:tag", []string{"bash"}) + require.NoError(t, err) + assert.Equal(t, "new:tag", pod.Spec.Containers[0].Image) +} + +func TestWaitForPodRunning(t *testing.T) { + ctx := context.Background() + pod := buildDebugPod("debug-ns", "img:tag", []string{"bash"}) + pod.Status.Phase = corev1.PodRunning + kube := fake.NewSimpleClientset(pod) + + require.NoError(t, waitForPodRunning(ctx, kube, "debug-ns", resourceName)) +} + +func TestWaitForPodRunningImagePullError(t *testing.T) { + ctx := context.Background() + pod := buildDebugPod("debug-ns", "img:tag", []string{"bash"}) + pod.Status.Phase = corev1.PodPending + pod.Status.ContainerStatuses = []corev1.ContainerStatus{{ + State: corev1.ContainerState{ + Waiting: &corev1.ContainerStateWaiting{ + Reason: "ErrImagePull", + Message: "not found", + }, + }, + }} + kube := fake.NewSimpleClientset(pod) + + err := waitForPodRunning(ctx, kube, "debug-ns", resourceName) + require.Error(t, err) + assert.Contains(t, err.Error(), "ErrImagePull") +} + +func TestNewCommandFlags(t *testing.T) { + cmd := NewCommand() + assert.Equal(t, "istio [-- command ...]", cmd.Use) + for _, name := range []string{"namespace", "target-namespace", "image", "kubeconfig", "context"} { + assert.NotNil(t, cmd.Flags().Lookup(name), "missing flag %s", name) + } +} diff --git a/internal/network/istio/istio.go b/internal/network/istio/istio.go new file mode 100644 index 000000000..6748cecd9 --- /dev/null +++ b/internal/network/istio/istio.go @@ -0,0 +1,133 @@ +/* +Copyright 2026 Flant JSC + +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 istio + +import ( + "fmt" + "os/signal" + "syscall" + + "github.com/spf13/cobra" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +var istioLong = templates.LongDesc(` +Start an interactive debug container with istioctl and the RBAC needed to +inspect pods in a target namespace (get/list pods and create pods/portforward). + +The ServiceAccount is created in --namespace. The Role and RoleBinding are +created in --target-namespace (defaults to --namespace). The debug image is +taken from ConfigMap d8-system/debug-container unless --image is set. + +The pod is deleted when the session ends. RBAC objects are left in place so +the next run can reuse them. + +© Flant JSC 2026`) + +var istioExample = templates.Examples(` + # Debug the current kubeconfig namespace + d8 network istio + + # Run the debug pod in one namespace with access to another + d8 network istio -n debug-tools --target-namespace production + + # Override the debug image + d8 network istio --image registry.example/debug:latest`) + +func NewCommand() *cobra.Command { + opts := Options{ + Command: []string{"bash"}, + } + + cmd := &cobra.Command{ + Use: "istio [-- command ...]", + Short: "Run an interactive istioctl debug container", + Long: istioLong, + Example: istioExample, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + // Match kubectl: first SIGINT/SIGTERM must stop the attach session + // instead of being swallowed by the d8 root graceful handler. + signal.Reset(syscall.SIGINT, syscall.SIGTERM) + + if len(args) > 0 { + opts.Command = args + } + + kubeconfigPath, err := cmd.Flags().GetString("kubeconfig") + if err != nil { + return fmt.Errorf("read --kubeconfig: %w", err) + } + + contextName, err := cmd.Flags().GetString("context") + if err != nil { + return fmt.Errorf("read --context: %w", err) + } + + restConfig, kubeCl, err := utilk8s.SetupK8sClientSet(kubeconfigPath, contextName) + if err != nil { + return fmt.Errorf("setup Kubernetes client: %w", err) + } + + if opts.Namespace == "" { + opts.Namespace, err = kubeconfigNamespace(kubeconfigPath, contextName) + if err != nil { + return err + } + } + + if opts.TargetNamespace == "" { + opts.TargetNamespace = opts.Namespace + } + + return Run(cmd.Context(), kubeCl, restConfig, opts) + }, + } + + cmd.Flags().StringVarP(&opts.Namespace, "namespace", "n", "", "Namespace for the ServiceAccount and debug pod (default: current kubeconfig namespace)") + cmd.Flags().StringVar(&opts.TargetNamespace, "target-namespace", "", "Namespace whose pods istioctl should be able to inspect (default: --namespace)") + cmd.Flags().StringVar(&opts.Image, "image", "", "Debug container image (default: ConfigMap d8-system/debug-container)") + cmd.Flags().StringP("kubeconfig", "k", utilk8s.DefaultKubeconfigPath(), "Path to kubeconfig file") + cmd.Flags().String("context", "", "The name of the kubeconfig context to use") + + return cmd +} + +func kubeconfigNamespace(kubeconfigPath, contextName string) (string, error) { + overrides := &clientcmd.ConfigOverrides{} + if contextName != utilk8s.DefaultKubeContext { + overrides.CurrentContext = contextName + } + + ns, _, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfigPath}, + overrides, + ).Namespace() + if err != nil { + return "", fmt.Errorf("resolve namespace from kubeconfig: %w", err) + } + + if ns == "" { + return "default", nil + } + + return ns, nil +} diff --git a/internal/network/network.go b/internal/network/network.go index 4232310b6..6c16f2392 100644 --- a/internal/network/network.go +++ b/internal/network/network.go @@ -21,6 +21,7 @@ import ( "k8s.io/kubectl/pkg/util/templates" cnimigration "github.com/deckhouse/deckhouse-cli/internal/network/cnimigration/cmd" + "github.com/deckhouse/deckhouse-cli/internal/network/istio" ) var networkLong = templates.LongDesc(` @@ -38,6 +39,7 @@ func NewCommand() *cobra.Command { networkCmd.AddCommand( cnimigration.NewCommand(), + istio.NewCommand(), ) return networkCmd From 18ff1f8035413a856322158683ae5861b4e12e2b Mon Sep 17 00:00:00 2001 From: Sergey Kurbatov Date: Mon, 24 Aug 2026 18:11:02 +0300 Subject: [PATCH 2/5] Fix linter Signed-off-by: Sergey Kurbatov --- internal/network/istio/debug.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/network/istio/debug.go b/internal/network/istio/debug.go index 3bf2c904b..9c5aaa49f 100644 --- a/internal/network/istio/debug.go +++ b/internal/network/istio/debug.go @@ -118,6 +118,7 @@ func ensureRBAC(ctx context.Context, kube kubernetes.Interface, debugNamespace, Namespace: debugNamespace, }, } + _, err := kube.CoreV1().ServiceAccounts(debugNamespace).Create(ctx, sa, metav1.CreateOptions{}) if err != nil && !apierrors.IsAlreadyExists(err) { return fmt.Errorf("create ServiceAccount %s/%s: %w", debugNamespace, resourceName, err) @@ -188,6 +189,7 @@ func createOrUpdateRole(ctx context.Context, kube kubernetes.Interface, role *rb } existing.Rules = role.Rules + _, err = kube.RbacV1().Roles(role.Namespace).Update(ctx, existing, metav1.UpdateOptions{}) if err != nil { return fmt.Errorf("update Role %s/%s: %w", role.Namespace, role.Name, err) @@ -213,6 +215,7 @@ func createOrUpdateRoleBinding(ctx context.Context, kube kubernetes.Interface, b existing.Subjects = binding.Subjects existing.RoleRef = binding.RoleRef + _, err = kube.RbacV1().RoleBindings(binding.Namespace).Update(ctx, existing, metav1.UpdateOptions{}) if err != nil { return fmt.Errorf("update RoleBinding %s/%s: %w", binding.Namespace, binding.Name, err) @@ -251,6 +254,7 @@ func buildDebugPod(namespace, image string, command []string) *corev1.Pod { func createDebugPod(ctx context.Context, kube kubernetes.Interface, namespace, image string, command []string) (*corev1.Pod, error) { if existing, err := kube.CoreV1().Pods(namespace).Get(ctx, resourceName, metav1.GetOptions{}); err == nil { fmt.Fprintf(os.Stderr, "Deleting leftover debug pod %s/%s\n", namespace, existing.Name) + if err := deletePod(ctx, kube, namespace, existing.Name); err != nil { return nil, err } From 0936d9f781b819a20f23e47ed1594959daa68bf6 Mon Sep 17 00:00:00 2001 From: Sergey Kurbatov Date: Thu, 27 Aug 2026 15:09:30 +0300 Subject: [PATCH 3/5] Correcting comments for the code Signed-off-by: Sergey Kurbatov --- internal/network/istio/debug.go | 111 ++++++++++++++++++--------- internal/network/istio/debug_test.go | 63 +++++++++++++-- internal/network/istio/istio.go | 55 +++++-------- 3 files changed, 150 insertions(+), 79 deletions(-) diff --git a/internal/network/istio/debug.go b/internal/network/istio/debug.go index 9c5aaa49f..8fb40637a 100644 --- a/internal/network/istio/debug.go +++ b/internal/network/istio/debug.go @@ -26,6 +26,7 @@ import ( rbacv1 "k8s.io/api/rbac/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" @@ -38,6 +39,10 @@ import ( const ( resourceName = "istioctl-debug" containerName = "istioctl-debug" + podGenerateName = resourceName + "-" + labelAppName = "app.kubernetes.io/name" + labelManagedBy = "app.kubernetes.io/managed-by" + managedByD8 = "d8" debugCMNamespace = "d8-system" debugCMName = "debug-container" debugCMImageKey = "image" @@ -76,7 +81,7 @@ func Run(ctx context.Context, kube kubernetes.Interface, restConfig *rest.Config cleanupCtx, cancel := context.WithTimeout(context.Background(), podDeleteTimeout) defer cancel() - if delErr := deletePod(cleanupCtx, kube, opts.Namespace, pod.Name); delErr != nil { + if delErr := deletePod(cleanupCtx, kube, opts.Namespace, pod.Name, pod.UID); delErr != nil { fmt.Fprintf(os.Stderr, "warning: failed to delete debug pod %s/%s: %v\n", opts.Namespace, pod.Name, delErr) } }() @@ -213,8 +218,7 @@ func createOrUpdateRoleBinding(ctx context.Context, kube kubernetes.Interface, b return fmt.Errorf("get RoleBinding %s/%s: %w", binding.Namespace, binding.Name, err) } - existing.Subjects = binding.Subjects - existing.RoleRef = binding.RoleRef + existing.Subjects = mergeSubjects(existing.Subjects, binding.Subjects) _, err = kube.RbacV1().RoleBindings(binding.Namespace).Update(ctx, existing, metav1.UpdateOptions{}) if err != nil { @@ -224,14 +228,45 @@ func createOrUpdateRoleBinding(ctx context.Context, kube kubernetes.Interface, b return nil } +func mergeSubjects(existing, add []rbacv1.Subject) []rbacv1.Subject { + out := append([]rbacv1.Subject(nil), existing...) + + for _, subject := range add { + if containsSubject(out, subject) { + continue + } + + out = append(out, subject) + } + + return out +} + +func containsSubject(subjects []rbacv1.Subject, want rbacv1.Subject) bool { + for _, subject := range subjects { + if subject.Kind == want.Kind && + subject.Name == want.Name && + subject.Namespace == want.Namespace && + subject.APIGroup == want.APIGroup { + return true + } + } + + return false +} + +func debugPodSelector() string { + return fmt.Sprintf("%s=%s,%s=%s", labelAppName, resourceName, labelManagedBy, managedByD8) +} + func buildDebugPod(namespace, image string, command []string) *corev1.Pod { return &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ - Name: resourceName, - Namespace: namespace, + GenerateName: podGenerateName, + Namespace: namespace, Labels: map[string]string{ - "app.kubernetes.io/name": resourceName, - "app.kubernetes.io/managed-by": "d8", + labelAppName: resourceName, + labelManagedBy: managedByD8, }, }, Spec: corev1.PodSpec{ @@ -252,52 +287,52 @@ func buildDebugPod(namespace, image string, command []string) *corev1.Pod { } func createDebugPod(ctx context.Context, kube kubernetes.Interface, namespace, image string, command []string) (*corev1.Pod, error) { - if existing, err := kube.CoreV1().Pods(namespace).Get(ctx, resourceName, metav1.GetOptions{}); err == nil { - fmt.Fprintf(os.Stderr, "Deleting leftover debug pod %s/%s\n", namespace, existing.Name) - - if err := deletePod(ctx, kube, namespace, existing.Name); err != nil { - return nil, err - } - - if err := waitForPodGone(ctx, kube, namespace, existing.Name); err != nil { - return nil, err - } - } else if !apierrors.IsNotFound(err) { - return nil, fmt.Errorf("get debug pod %s/%s: %w", namespace, resourceName, err) + if err := deleteTerminalDebugPods(ctx, kube, namespace); err != nil { + return nil, err } pod, err := kube.CoreV1().Pods(namespace).Create(ctx, buildDebugPod(namespace, image, command), metav1.CreateOptions{}) if err != nil { - return nil, fmt.Errorf("create debug pod %s/%s: %w", namespace, resourceName, err) + return nil, fmt.Errorf("create debug pod in %s: %w", namespace, err) } return pod, nil } -func deletePod(ctx context.Context, kube kubernetes.Interface, namespace, name string) error { - err := kube.CoreV1().Pods(namespace).Delete(ctx, name, metav1.DeleteOptions{ - GracePeriodSeconds: ptr.To(podDeleteGraceSec), - }) - if apierrors.IsNotFound(err) { - return nil +func deleteTerminalDebugPods(ctx context.Context, kube kubernetes.Interface, namespace string) error { + list, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{LabelSelector: debugPodSelector()}) + if err != nil { + return fmt.Errorf("list leftover debug pods in %s: %w", namespace, err) } - return err -} - -func waitForPodGone(ctx context.Context, kube kubernetes.Interface, namespace, name string) error { - return wait.PollUntilContextTimeout(ctx, podPollInterval, podReadyTimeout, true, func(ctx context.Context) (bool, error) { - _, err := kube.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{}) - if apierrors.IsNotFound(err) { - return true, nil + for i := range list.Items { + pod := &list.Items[i] + if pod.Status.Phase != corev1.PodSucceeded && pod.Status.Phase != corev1.PodFailed { + continue } - if err != nil { - return false, err + fmt.Fprintf(os.Stderr, "Deleting leftover debug pod %s/%s\n", namespace, pod.Name) + + if delErr := deletePod(ctx, kube, namespace, pod.Name, pod.UID); delErr != nil { + fmt.Fprintf(os.Stderr, "warning: failed to delete leftover debug pod %s/%s: %v\n", namespace, pod.Name, delErr) } + } - return false, nil - }) + return nil +} + +func deletePod(ctx context.Context, kube kubernetes.Interface, namespace, name string, uid types.UID) error { + opts := metav1.DeleteOptions{GracePeriodSeconds: ptr.To(podDeleteGraceSec)} + if uid != "" { + opts.Preconditions = &metav1.Preconditions{UID: &uid} + } + + err := kube.CoreV1().Pods(namespace).Delete(ctx, name, opts) + if apierrors.IsNotFound(err) || apierrors.IsConflict(err) { + return nil + } + + return err } func waitForPodRunning(ctx context.Context, kube kubernetes.Interface, namespace, name string) error { diff --git a/internal/network/istio/debug_test.go b/internal/network/istio/debug_test.go index ab07461b5..b2be6d182 100644 --- a/internal/network/istio/debug_test.go +++ b/internal/network/istio/debug_test.go @@ -24,8 +24,11 @@ import ( "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/fake" + clienttesting "k8s.io/client-go/testing" ) func TestEnsureRBACCreatesObjects(t *testing.T) { @@ -71,6 +74,20 @@ func TestEnsureRBACIsIdempotentAndUpdatesRules(t *testing.T) { assert.Equal(t, istioctlDebugRules(), role.Rules) } +func TestEnsureRBACMergesRoleBindingSubjects(t *testing.T) { + kube := fake.NewSimpleClientset() + ctx := context.Background() + + require.NoError(t, ensureRBAC(ctx, kube, "debug-ns", "target-ns")) + require.NoError(t, ensureRBAC(ctx, kube, "other-ns", "target-ns")) + + binding, err := kube.RbacV1().RoleBindings("target-ns").Get(ctx, resourceName, metav1.GetOptions{}) + require.NoError(t, err) + require.Len(t, binding.Subjects, 2) + assert.Equal(t, "debug-ns", binding.Subjects[0].Namespace) + assert.Equal(t, "other-ns", binding.Subjects[1].Namespace) +} + func TestResolveDebugImage(t *testing.T) { ctx := context.Background() @@ -112,8 +129,11 @@ func TestResolveDebugImage(t *testing.T) { func TestBuildDebugPod(t *testing.T) { pod := buildDebugPod("debug-ns", "img:tag", []string{"bash"}) - assert.Equal(t, resourceName, pod.Name) + assert.Empty(t, pod.Name) + assert.Equal(t, podGenerateName, pod.GenerateName) assert.Equal(t, "debug-ns", pod.Namespace) + assert.Equal(t, resourceName, pod.Labels[labelAppName]) + assert.Equal(t, managedByD8, pod.Labels[labelManagedBy]) assert.Equal(t, resourceName, pod.Spec.ServiceAccountName) require.NotNil(t, pod.Spec.AutomountServiceAccountToken) assert.True(t, *pod.Spec.AutomountServiceAccountToken) @@ -127,19 +147,39 @@ func TestBuildDebugPod(t *testing.T) { assert.True(t, c.TTY) } -func TestCreateDebugPodReplacesLeftover(t *testing.T) { +func TestCreateDebugPodGarbageCollectsTerminalOnly(t *testing.T) { ctx := context.Background() - leftover := buildDebugPod("debug-ns", "old:tag", []string{"bash"}) - kube := fake.NewSimpleClientset(leftover) + + terminal := buildDebugPod("debug-ns", "old:tag", []string{"bash"}) + terminal.Name = "istioctl-debug-old" + terminal.UID = "uid-old" + terminal.Status.Phase = corev1.PodSucceeded + + running := buildDebugPod("debug-ns", "live:tag", []string{"bash"}) + running.Name = "istioctl-debug-live" + running.UID = "uid-live" + running.Status.Phase = corev1.PodRunning + + kube := fake.NewSimpleClientset(terminal, running) + prependGenerateName(kube) pod, err := createDebugPod(ctx, kube, "debug-ns", "new:tag", []string{"bash"}) require.NoError(t, err) + assert.Equal(t, "istioctl-debug-testhash", pod.Name) assert.Equal(t, "new:tag", pod.Spec.Containers[0].Image) + + _, err = kube.CoreV1().Pods("debug-ns").Get(ctx, terminal.Name, metav1.GetOptions{}) + require.True(t, apierrors.IsNotFound(err)) + + live, err := kube.CoreV1().Pods("debug-ns").Get(ctx, running.Name, metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, corev1.PodRunning, live.Status.Phase) } func TestWaitForPodRunning(t *testing.T) { ctx := context.Background() pod := buildDebugPod("debug-ns", "img:tag", []string{"bash"}) + pod.Name = resourceName pod.Status.Phase = corev1.PodRunning kube := fake.NewSimpleClientset(pod) @@ -149,6 +189,7 @@ func TestWaitForPodRunning(t *testing.T) { func TestWaitForPodRunningImagePullError(t *testing.T) { ctx := context.Background() pod := buildDebugPod("debug-ns", "img:tag", []string{"bash"}) + pod.Name = resourceName pod.Status.Phase = corev1.PodPending pod.Status.ContainerStatuses = []corev1.ContainerStatus{{ State: corev1.ContainerState{ @@ -167,8 +208,20 @@ func TestWaitForPodRunningImagePullError(t *testing.T) { func TestNewCommandFlags(t *testing.T) { cmd := NewCommand() - assert.Equal(t, "istio [-- command ...]", cmd.Use) + assert.Equal(t, "istio", cmd.Use) for _, name := range []string{"namespace", "target-namespace", "image", "kubeconfig", "context"} { assert.NotNil(t, cmd.Flags().Lookup(name), "missing flag %s", name) } } + +func prependGenerateName(kube *fake.Clientset) { + kube.PrependReactor("create", "pods", func(action clienttesting.Action) (bool, runtime.Object, error) { + pod := action.(clienttesting.CreateAction).GetObject().(*corev1.Pod) + if pod.Name == "" && pod.GenerateName != "" { + pod.Name = pod.GenerateName + "testhash" + pod.UID = "uid-testhash" + } + + return false, nil, nil + }) +} diff --git a/internal/network/istio/istio.go b/internal/network/istio/istio.go index 6748cecd9..2e753734a 100644 --- a/internal/network/istio/istio.go +++ b/internal/network/istio/istio.go @@ -17,14 +17,14 @@ limitations under the License. package istio import ( + "context" + "errors" "fmt" - "os/signal" - "syscall" "github.com/spf13/cobra" - "k8s.io/client-go/tools/clientcmd" "k8s.io/kubectl/pkg/util/templates" + "github.com/deckhouse/deckhouse-cli/internal/snapshot/transport" "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) @@ -57,21 +57,17 @@ func NewCommand() *cobra.Command { } cmd := &cobra.Command{ - Use: "istio [-- command ...]", + Use: "istio", Short: "Run an interactive istioctl debug container", Long: istioLong, Example: istioExample, + Args: cobra.NoArgs, SilenceUsage: true, SilenceErrors: true, - RunE: func(cmd *cobra.Command, args []string) error { - // Match kubectl: first SIGINT/SIGTERM must stop the attach session - // instead of being swallowed by the d8 root graceful handler. - signal.Reset(syscall.SIGINT, syscall.SIGTERM) - - if len(args) > 0 { - opts.Command = args - } - + RunE: func(cmd *cobra.Command, _ []string) error { + // Interactive bash only. One-shot `istio -- command` is not supported: + // poll-then-attach loses early stdout, treats a fast Succeeded pod as an + // error, and cannot propagate the container exit code (attach has none). kubeconfigPath, err := cmd.Flags().GetString("kubeconfig") if err != nil { return fmt.Errorf("read --kubeconfig: %w", err) @@ -88,7 +84,7 @@ func NewCommand() *cobra.Command { } if opts.Namespace == "" { - opts.Namespace, err = kubeconfigNamespace(kubeconfigPath, contextName) + opts.Namespace, err = transport.KubeconfigNamespace(kubeconfigPath, contextName) if err != nil { return err } @@ -98,7 +94,15 @@ func NewCommand() *cobra.Command { opts.TargetNamespace = opts.Namespace } - return Run(cmd.Context(), kubeCl, restConfig, opts) + err = Run(cmd.Context(), kubeCl, restConfig, opts) + if errors.Is(err, context.Canceled) { + // SIGINT/SIGTERM cancels cmd.Context() via the root graceful + // handler. Run already deletes the debug pod in a defer that + // uses context.Background(), so treat cancel as a clean stop. + return nil + } + + return err }, } @@ -110,24 +114,3 @@ func NewCommand() *cobra.Command { return cmd } - -func kubeconfigNamespace(kubeconfigPath, contextName string) (string, error) { - overrides := &clientcmd.ConfigOverrides{} - if contextName != utilk8s.DefaultKubeContext { - overrides.CurrentContext = contextName - } - - ns, _, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( - &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfigPath}, - overrides, - ).Namespace() - if err != nil { - return "", fmt.Errorf("resolve namespace from kubeconfig: %w", err) - } - - if ns == "" { - return "default", nil - } - - return ns, nil -} From c905073059321a06e64bcdb5fac0a427ff954828 Mon Sep 17 00:00:00 2001 From: Sergey Kurbatov Date: Thu, 27 Aug 2026 16:40:45 +0300 Subject: [PATCH 4/5] Add access to istio ns Signed-off-by: Sergey Kurbatov --- internal/network/istio/debug.go | 48 ++++++++++++++++++++++------ internal/network/istio/debug_test.go | 37 +++++++++++++++++---- internal/network/istio/istio.go | 15 ++++++--- 3 files changed, 81 insertions(+), 19 deletions(-) diff --git a/internal/network/istio/debug.go b/internal/network/istio/debug.go index 8fb40637a..ab902558f 100644 --- a/internal/network/istio/debug.go +++ b/internal/network/istio/debug.go @@ -20,6 +20,7 @@ import ( "context" "fmt" "os" + "strings" "time" corev1 "k8s.io/api/core/v1" @@ -46,6 +47,7 @@ const ( debugCMNamespace = "d8-system" debugCMName = "debug-container" debugCMImageKey = "image" + defaultIstioNS = "d8-istio" podReadyTimeout = 2 * time.Minute podPollInterval = time.Second podDeleteTimeout = 15 * time.Second @@ -56,6 +58,7 @@ const ( type Options struct { Namespace string TargetNamespace string + IstioNamespace string Image string Command []string } @@ -68,7 +71,7 @@ func Run(ctx context.Context, kube kubernetes.Interface, restConfig *rest.Config return err } - if err := ensureRBAC(ctx, kube, opts.Namespace, opts.TargetNamespace); err != nil { + if err := ensureRBAC(ctx, kube, opts.Namespace, opts.TargetNamespace, opts.IstioNamespace); err != nil { return err } @@ -92,8 +95,8 @@ func Run(ctx context.Context, kube kubernetes.Interface, restConfig *rest.Config return fmt.Errorf("wait for debug pod: %w", err) } - fmt.Fprintf(os.Stderr, "Attached to %s/%s (image %s). RBAC target namespace: %s\n", - opts.Namespace, pod.Name, image, opts.TargetNamespace) + fmt.Fprintf(os.Stderr, "Attached to %s/%s (image %s). RBAC namespaces: %s\n", + opts.Namespace, pod.Name, image, strings.Join(uniqueNonEmpty(opts.TargetNamespace, opts.IstioNamespace), ", ")) return attachToPod(ctx, kube, restConfig, opts.Namespace, pod.Name) } @@ -116,7 +119,7 @@ func resolveDebugImage(ctx context.Context, kube kubernetes.Interface, override return image, nil } -func ensureRBAC(ctx context.Context, kube kubernetes.Interface, debugNamespace, targetNamespace string) error { +func ensureRBAC(ctx context.Context, kube kubernetes.Interface, debugNamespace, targetNamespace, istioNamespace string) error { sa := &corev1.ServiceAccount{ ObjectMeta: metav1.ObjectMeta{ Name: resourceName, @@ -129,10 +132,20 @@ func ensureRBAC(ctx context.Context, kube kubernetes.Interface, debugNamespace, return fmt.Errorf("create ServiceAccount %s/%s: %w", debugNamespace, resourceName, err) } + for _, ns := range uniqueNonEmpty(targetNamespace, istioNamespace) { + if err := ensureNamespaceAccess(ctx, kube, debugNamespace, ns); err != nil { + return err + } + } + + return nil +} + +func ensureNamespaceAccess(ctx context.Context, kube kubernetes.Interface, debugNamespace, roleNamespace string) error { role := &rbacv1.Role{ ObjectMeta: metav1.ObjectMeta{ Name: resourceName, - Namespace: targetNamespace, + Namespace: roleNamespace, }, Rules: istioctlDebugRules(), } @@ -143,7 +156,7 @@ func ensureRBAC(ctx context.Context, kube kubernetes.Interface, debugNamespace, binding := &rbacv1.RoleBinding{ ObjectMeta: metav1.ObjectMeta{ Name: resourceName, - Namespace: targetNamespace, + Namespace: roleNamespace, }, Subjects: []rbacv1.Subject{{ Kind: rbacv1.ServiceAccountKind, @@ -156,11 +169,28 @@ func ensureRBAC(ctx context.Context, kube kubernetes.Interface, debugNamespace, Name: resourceName, }, } - if err := createOrUpdateRoleBinding(ctx, kube, binding); err != nil { - return err + + return createOrUpdateRoleBinding(ctx, kube, binding) +} + +func uniqueNonEmpty(values ...string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + + for _, value := range values { + if value == "" { + continue + } + + if _, ok := seen[value]; ok { + continue + } + + seen[value] = struct{}{} + out = append(out, value) } - return nil + return out } func istioctlDebugRules() []rbacv1.PolicyRule { diff --git a/internal/network/istio/debug_test.go b/internal/network/istio/debug_test.go index b2be6d182..c03472ef0 100644 --- a/internal/network/istio/debug_test.go +++ b/internal/network/istio/debug_test.go @@ -35,7 +35,7 @@ func TestEnsureRBACCreatesObjects(t *testing.T) { kube := fake.NewSimpleClientset() ctx := context.Background() - require.NoError(t, ensureRBAC(ctx, kube, "debug-ns", "target-ns")) + require.NoError(t, ensureRBAC(ctx, kube, "debug-ns", "target-ns", "")) sa, err := kube.CoreV1().ServiceAccounts("debug-ns").Get(ctx, resourceName, metav1.GetOptions{}) require.NoError(t, err) @@ -59,7 +59,7 @@ func TestEnsureRBACIsIdempotentAndUpdatesRules(t *testing.T) { kube := fake.NewSimpleClientset() ctx := context.Background() - require.NoError(t, ensureRBAC(ctx, kube, "debug-ns", "target-ns")) + require.NoError(t, ensureRBAC(ctx, kube, "debug-ns", "target-ns", "")) role, err := kube.RbacV1().Roles("target-ns").Get(ctx, resourceName, metav1.GetOptions{}) require.NoError(t, err) @@ -67,7 +67,7 @@ func TestEnsureRBACIsIdempotentAndUpdatesRules(t *testing.T) { _, err = kube.RbacV1().Roles("target-ns").Update(ctx, role, metav1.UpdateOptions{}) require.NoError(t, err) - require.NoError(t, ensureRBAC(ctx, kube, "debug-ns", "target-ns")) + require.NoError(t, ensureRBAC(ctx, kube, "debug-ns", "target-ns", "")) role, err = kube.RbacV1().Roles("target-ns").Get(ctx, resourceName, metav1.GetOptions{}) require.NoError(t, err) @@ -78,8 +78,8 @@ func TestEnsureRBACMergesRoleBindingSubjects(t *testing.T) { kube := fake.NewSimpleClientset() ctx := context.Background() - require.NoError(t, ensureRBAC(ctx, kube, "debug-ns", "target-ns")) - require.NoError(t, ensureRBAC(ctx, kube, "other-ns", "target-ns")) + require.NoError(t, ensureRBAC(ctx, kube, "debug-ns", "target-ns", "")) + require.NoError(t, ensureRBAC(ctx, kube, "other-ns", "target-ns", "")) binding, err := kube.RbacV1().RoleBindings("target-ns").Get(ctx, resourceName, metav1.GetOptions{}) require.NoError(t, err) @@ -88,6 +88,31 @@ func TestEnsureRBACMergesRoleBindingSubjects(t *testing.T) { assert.Equal(t, "other-ns", binding.Subjects[1].Namespace) } +func TestEnsureRBACGrantsIstioNamespaceRead(t *testing.T) { + kube := fake.NewSimpleClientset() + ctx := context.Background() + + require.NoError(t, ensureRBAC(ctx, kube, "default", "app-ns", defaultIstioNS)) + + role, err := kube.RbacV1().Roles(defaultIstioNS).Get(ctx, resourceName, metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, istioctlDebugRules(), role.Rules) + + for _, rule := range role.Rules { + assert.NotContains(t, rule.Verbs, "update") + assert.NotContains(t, rule.Verbs, "patch") + assert.NotContains(t, rule.Verbs, "delete") + } + + binding, err := kube.RbacV1().RoleBindings(defaultIstioNS).Get(ctx, resourceName, metav1.GetOptions{}) + require.NoError(t, err) + require.Len(t, binding.Subjects, 1) + assert.Equal(t, "default", binding.Subjects[0].Namespace) + + _, err = kube.RbacV1().Roles("app-ns").Get(ctx, resourceName, metav1.GetOptions{}) + require.NoError(t, err) +} + func TestResolveDebugImage(t *testing.T) { ctx := context.Background() @@ -209,7 +234,7 @@ func TestWaitForPodRunningImagePullError(t *testing.T) { func TestNewCommandFlags(t *testing.T) { cmd := NewCommand() assert.Equal(t, "istio", cmd.Use) - for _, name := range []string{"namespace", "target-namespace", "image", "kubeconfig", "context"} { + for _, name := range []string{"namespace", "target-namespace", "istio-namespace", "image", "kubeconfig", "context"} { assert.NotNil(t, cmd.Flags().Lookup(name), "missing flag %s", name) } } diff --git a/internal/network/istio/istio.go b/internal/network/istio/istio.go index 2e753734a..a70750493 100644 --- a/internal/network/istio/istio.go +++ b/internal/network/istio/istio.go @@ -32,9 +32,11 @@ var istioLong = templates.LongDesc(` Start an interactive debug container with istioctl and the RBAC needed to inspect pods in a target namespace (get/list pods and create pods/portforward). -The ServiceAccount is created in --namespace. The Role and RoleBinding are -created in --target-namespace (defaults to --namespace). The debug image is -taken from ConfigMap d8-system/debug-container unless --image is set. +The ServiceAccount is created in --namespace. A Role and RoleBinding (pods +get/list and pods/portforward create — enough to read sidecar/istiod status, +not to mutate Istio config) are created in --target-namespace and in +--istio-namespace (default d8-istio, for istioctl proxy-status). The debug +image is taken from ConfigMap d8-system/debug-container unless --image is set. The pod is deleted when the session ends. RBAC objects are left in place so the next run can reuse them. @@ -94,6 +96,10 @@ func NewCommand() *cobra.Command { opts.TargetNamespace = opts.Namespace } + if opts.IstioNamespace == "" { + opts.IstioNamespace = defaultIstioNS + } + err = Run(cmd.Context(), kubeCl, restConfig, opts) if errors.Is(err, context.Canceled) { // SIGINT/SIGTERM cancels cmd.Context() via the root graceful @@ -107,7 +113,8 @@ func NewCommand() *cobra.Command { } cmd.Flags().StringVarP(&opts.Namespace, "namespace", "n", "", "Namespace for the ServiceAccount and debug pod (default: current kubeconfig namespace)") - cmd.Flags().StringVar(&opts.TargetNamespace, "target-namespace", "", "Namespace whose pods istioctl should be able to inspect (default: --namespace)") + cmd.Flags().StringVar(&opts.TargetNamespace, "target-namespace", "", "Namespace whose workload pods istioctl should inspect (default: --namespace)") + cmd.Flags().StringVar(&opts.IstioNamespace, "istio-namespace", defaultIstioNS, "Istio control-plane namespace for read-only access (istioctl proxy-status)") cmd.Flags().StringVar(&opts.Image, "image", "", "Debug container image (default: ConfigMap d8-system/debug-container)") cmd.Flags().StringP("kubeconfig", "k", utilk8s.DefaultKubeconfigPath(), "Path to kubeconfig file") cmd.Flags().String("context", "", "The name of the kubeconfig context to use") From 70679bc738eeb2c990315d41e590d4a31c8bed2f Mon Sep 17 00:00:00 2001 From: Sergey Kurbatov Date: Thu, 27 Aug 2026 17:13:54 +0300 Subject: [PATCH 5/5] fix Signed-off-by: Sergey Kurbatov --- internal/network/istio/debug.go | 35 ++++++++++++++++++++++++---- internal/network/istio/debug_test.go | 13 +++++++---- internal/network/istio/istio.go | 11 +++++---- 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/internal/network/istio/debug.go b/internal/network/istio/debug.go index ab902558f..0f903f331 100644 --- a/internal/network/istio/debug.go +++ b/internal/network/istio/debug.go @@ -133,7 +133,12 @@ func ensureRBAC(ctx context.Context, kube kubernetes.Interface, debugNamespace, } for _, ns := range uniqueNonEmpty(targetNamespace, istioNamespace) { - if err := ensureNamespaceAccess(ctx, kube, debugNamespace, ns); err != nil { + rules := istioctlWorkloadRules() + if ns == istioNamespace { + rules = istioctlIstioControlPlaneRules() + } + + if err := ensureNamespaceAccess(ctx, kube, debugNamespace, ns, rules); err != nil { return err } } @@ -141,13 +146,18 @@ func ensureRBAC(ctx context.Context, kube kubernetes.Interface, debugNamespace, return nil } -func ensureNamespaceAccess(ctx context.Context, kube kubernetes.Interface, debugNamespace, roleNamespace string) error { +func ensureNamespaceAccess( + ctx context.Context, + kube kubernetes.Interface, + debugNamespace, roleNamespace string, + rules []rbacv1.PolicyRule, +) error { role := &rbacv1.Role{ ObjectMeta: metav1.ObjectMeta{ Name: resourceName, Namespace: roleNamespace, }, - Rules: istioctlDebugRules(), + Rules: rules, } if err := createOrUpdateRole(ctx, kube, role); err != nil { return err @@ -193,7 +203,7 @@ func uniqueNonEmpty(values ...string) []string { return out } -func istioctlDebugRules() []rbacv1.PolicyRule { +func istioctlWorkloadRules() []rbacv1.PolicyRule { return []rbacv1.PolicyRule{ { APIGroups: []string{""}, @@ -208,6 +218,23 @@ func istioctlDebugRules() []rbacv1.PolicyRule { } } +// istioctlIstioControlPlaneRules adds TokenRequest access istioctl needs to +// authenticate RPC calls to istiod (e.g. proxy-status). No Istio CR writes. +func istioctlIstioControlPlaneRules() []rbacv1.PolicyRule { + return append(istioctlWorkloadRules(), + rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"serviceaccounts"}, + Verbs: []string{"get"}, + }, + rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"serviceaccounts/token"}, + Verbs: []string{"create"}, + }, + ) +} + func createOrUpdateRole(ctx context.Context, kube kubernetes.Interface, role *rbacv1.Role) error { existing, err := kube.RbacV1().Roles(role.Namespace).Get(ctx, role.Name, metav1.GetOptions{}) if apierrors.IsNotFound(err) { diff --git a/internal/network/istio/debug_test.go b/internal/network/istio/debug_test.go index c03472ef0..e6a834490 100644 --- a/internal/network/istio/debug_test.go +++ b/internal/network/istio/debug_test.go @@ -43,7 +43,7 @@ func TestEnsureRBACCreatesObjects(t *testing.T) { role, err := kube.RbacV1().Roles("target-ns").Get(ctx, resourceName, metav1.GetOptions{}) require.NoError(t, err) - assert.Equal(t, istioctlDebugRules(), role.Rules) + assert.Equal(t, istioctlWorkloadRules(), role.Rules) binding, err := kube.RbacV1().RoleBindings("target-ns").Get(ctx, resourceName, metav1.GetOptions{}) require.NoError(t, err) @@ -71,7 +71,7 @@ func TestEnsureRBACIsIdempotentAndUpdatesRules(t *testing.T) { role, err = kube.RbacV1().Roles("target-ns").Get(ctx, resourceName, metav1.GetOptions{}) require.NoError(t, err) - assert.Equal(t, istioctlDebugRules(), role.Rules) + assert.Equal(t, istioctlWorkloadRules(), role.Rules) } func TestEnsureRBACMergesRoleBindingSubjects(t *testing.T) { @@ -96,7 +96,7 @@ func TestEnsureRBACGrantsIstioNamespaceRead(t *testing.T) { role, err := kube.RbacV1().Roles(defaultIstioNS).Get(ctx, resourceName, metav1.GetOptions{}) require.NoError(t, err) - assert.Equal(t, istioctlDebugRules(), role.Rules) + assert.Equal(t, istioctlIstioControlPlaneRules(), role.Rules) for _, rule := range role.Rules { assert.NotContains(t, rule.Verbs, "update") @@ -109,8 +109,13 @@ func TestEnsureRBACGrantsIstioNamespaceRead(t *testing.T) { require.Len(t, binding.Subjects, 1) assert.Equal(t, "default", binding.Subjects[0].Namespace) - _, err = kube.RbacV1().Roles("app-ns").Get(ctx, resourceName, metav1.GetOptions{}) + appRole, err := kube.RbacV1().Roles("app-ns").Get(ctx, resourceName, metav1.GetOptions{}) require.NoError(t, err) + assert.Equal(t, istioctlWorkloadRules(), appRole.Rules) + + for _, rule := range appRole.Rules { + assert.NotContains(t, rule.Resources, "serviceaccounts/token") + } } func TestResolveDebugImage(t *testing.T) { diff --git a/internal/network/istio/istio.go b/internal/network/istio/istio.go index a70750493..efff9763d 100644 --- a/internal/network/istio/istio.go +++ b/internal/network/istio/istio.go @@ -32,11 +32,12 @@ var istioLong = templates.LongDesc(` Start an interactive debug container with istioctl and the RBAC needed to inspect pods in a target namespace (get/list pods and create pods/portforward). -The ServiceAccount is created in --namespace. A Role and RoleBinding (pods -get/list and pods/portforward create — enough to read sidecar/istiod status, -not to mutate Istio config) are created in --target-namespace and in ---istio-namespace (default d8-istio, for istioctl proxy-status). The debug -image is taken from ConfigMap d8-system/debug-container unless --image is set. +The ServiceAccount is created in --namespace. Roles and RoleBindings are +created in --target-namespace (workload pods) and --istio-namespace (default +d8-istio): pods get/list, pods/portforward create, and in the Istio namespace +also serviceaccounts get plus serviceaccounts/token create for istiod RPC +auth (istioctl proxy-status). No Istio config mutations. The debug image is +taken from ConfigMap d8-system/debug-container unless --image is set. The pod is deleted when the session ends. RBAC objects are left in place so the next run can reuse them.