From ad98037111b32ce2bceef3806ead11590eb1bb00 Mon Sep 17 00:00:00 2001 From: Pavel Tishkov Date: Thu, 4 Dec 2025 20:47:47 +0300 Subject: [PATCH 1/4] feat(cli): add debug bundle Signed-off-by: Pavel Tishkov --- src/cli/README.md | 66 ++- src/cli/internal/clientconfig/clientconfig.go | 13 + .../cmd/collectdebuginfo/collectdebuginfo.go | 124 +++++ .../cmd/collectdebuginfo/collectors.go | 457 ++++++++++++++++++ src/cli/pkg/command/virtualization.go | 2 + 5 files changed, 637 insertions(+), 25 deletions(-) create mode 100644 src/cli/internal/cmd/collectdebuginfo/collectdebuginfo.go create mode 100644 src/cli/internal/cmd/collectdebuginfo/collectors.go diff --git a/src/cli/README.md b/src/cli/README.md index 48c0344541..cf33759772 100644 --- a/src/cli/README.md +++ b/src/cli/README.md @@ -8,6 +8,7 @@ Manages virtual machine-related operations in your Kubernetes cluster. | Command | Description | |--------------------|------------------------------------------------------------------------| | ansible-inventory | Generate ansible inventory from virtual machines | +| collect-debug-info | Collect debug information for VM: configuration, events, and logs | | console | Connect to a console of a virtual machine. | | port-forward | Forward local ports to a virtual machine. | | scp | SCP files from/to a virtual machine. | @@ -24,80 +25,95 @@ Manages virtual machine-related operations in your Kubernetes cluster. ```shell # Get inventory for default namespace in JSON format -d8 virtualization ansible-inventory -d8 virtualization ansible-inventory --list +d8 v ansible-inventory +d8 v ansible-inventory --list # Get host variables -d8 virtualization ansible-inventory --host myvm.mynamespace +d8 v ansible-inventory --host myvm.mynamespace # Specify namespace -d8 virtualization ansible-inventory -n mynamespace +d8 v ansible-inventory -n mynamespace # Specify output format (json, ini, yaml) -d8 virtualization ansible-inventory -o json -d8 virtualization ansible-inventory -o yaml -d8 virtualization ansible-inventory -o ini +d8 v ansible-inventory -o json +d8 v ansible-inventory -o yaml +d8 v ansible-inventory -o ini ``` #### console ```shell -d8 virtualization console myvm -d8 virtualization console myvm.mynamespace +d8 v console myvm +d8 v console myvm.mynamespace ``` #### port-forward ```shell -d8 virtualization port-forward myvm tcp/8080:8080 -d8 virtualization port-forward --stdio=true myvm.mynamespace 22 +d8 v port-forward myvm tcp/8080:8080 +d8 v port-forward --stdio=true myvm.mynamespace 22 ``` #### scp ```shell -d8 virtualization scp myfile.bin user@myvm:myfile.bin -d8 virtualization scp user@myvm:myfile.bin ~/myfile.bin +d8 v scp myfile.bin user@myvm:myfile.bin +d8 v scp user@myvm:myfile.bin ~/myfile.bin ``` #### ssh ```shell -d8 virtualization --identity-file=/path/to/ssh_key ssh user@myvm.mynamespace -d8 virtualization ssh --local-ssh=true --namespace=mynamespace --username=user myvm +d8 v --identity-file=/path/to/ssh_key ssh user@myvm.mynamespace +d8 v ssh --local-ssh=true --namespace=mynamespace --username=user myvm ``` #### vnc ```shell -d8 virtualization vnc myvm.mynamespace -d8 virtualization vnc myvm -n mynamespace +d8 v vnc myvm.mynamespace +d8 v vnc myvm -n mynamespace ``` #### start ```shell -d8 virtualization start myvm.mynamespace --wait -d8 virtualization start myvm -n mynamespace +d8 v start myvm.mynamespace --wait +d8 v start myvm -n mynamespace ``` #### stop ```shell -d8 virtualization stop myvm.mynamespace --force -d8 virtualization stop myvm -n mynamespace +d8 v stop myvm.mynamespace --force +d8 v stop myvm -n mynamespace ``` #### restart ```shell -d8 virtualization restart myvm.mynamespace --timeout=1m -d8 virtualization restart myvm -n mynamespace +d8 v restart myvm.mynamespace --timeout=1m +d8 v restart myvm -n mynamespace ``` #### evict ```shell -d8 virtualization evict myvm.mynamespace -d8 virtualization evict myvm -n mynamespace +d8 v evict myvm.mynamespace +d8 v evict myvm -n mynamespace +``` + +#### collect-debug-info + +```shell +# Collect debug info for VirtualMachine 'myvm' +d8 v collect-debug-info myvm +d8 v collect-debug-info myvm.mynamespace +d8 v collect-debug-info myvm -n mynamespace + +# Include pod logs in output +d8 v collect-debug-info --with-logs myvm + +# Enable debug output for permission errors +d8 v collect-debug-info --debug myvm ``` diff --git a/src/cli/internal/clientconfig/clientconfig.go b/src/cli/internal/clientconfig/clientconfig.go index 1b7c9f3d99..2b502366ca 100644 --- a/src/cli/internal/clientconfig/clientconfig.go +++ b/src/cli/internal/clientconfig/clientconfig.go @@ -20,6 +20,7 @@ import ( "context" "fmt" + "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" "github.com/deckhouse/virtualization/api/client/kubeclient" @@ -49,3 +50,15 @@ func ClientAndNamespaceFromContext(ctx context.Context) (client kubeclient.Clien } return client, namespace, overridden, nil } + +func GetRESTConfig(ctx context.Context) (*rest.Config, error) { + clientConfig, ok := ctx.Value(clientConfigKey).(clientcmd.ClientConfig) + if !ok { + return nil, fmt.Errorf("unable to get client config from context") + } + config, err := clientConfig.ClientConfig() + if err != nil { + return nil, err + } + return config, nil +} diff --git a/src/cli/internal/cmd/collectdebuginfo/collectdebuginfo.go b/src/cli/internal/cmd/collectdebuginfo/collectdebuginfo.go new file mode 100644 index 0000000000..680b5ef0c1 --- /dev/null +++ b/src/cli/internal/cmd/collectdebuginfo/collectdebuginfo.go @@ -0,0 +1,124 @@ +/* +Copyright 2025 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 collectdebuginfo + +import ( + "context" + "fmt" + "io" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/client-go/dynamic" + + "github.com/deckhouse/virtualization/api/client/kubeclient" + "github.com/deckhouse/virtualization/src/cli/internal/clientconfig" + "github.com/deckhouse/virtualization/src/cli/internal/templates" +) + +func NewCommand() *cobra.Command { + bundle := &DebugBundle{} + cmd := &cobra.Command{ + Use: "collect-debug-info (VirtualMachine)", + Short: "Collect debug information for VM: configuration, events, and logs.", + Example: usage(), + Args: templates.ExactArgs("collect-debug-info", 1), + RunE: bundle.Run, + } + + cmd.Flags().BoolVar(&bundle.saveLogs, "with-logs", false, "Include pod logs in output") + cmd.Flags().BoolVar(&bundle.debug, "debug", false, "Enable debug output for permission errors") + cmd.SetUsageTemplate(templates.UsageTemplate()) + return cmd +} + +type DebugBundle struct { + saveLogs bool + debug bool + dynamicClient dynamic.Interface + stdout io.Writer + stderr io.Writer + resourceCount int +} + +func usage() string { + return ` # Collect debug info for VirtualMachine 'myvm': + {{ProgramName}} collect-debug-info myvm + {{ProgramName}} collect-debug-info myvm.mynamespace + {{ProgramName}} collect-debug-info myvm -n mynamespace + # Include pod logs: + {{ProgramName}} collect-debug-info --with-logs myvm` +} + +func (b *DebugBundle) Run(cmd *cobra.Command, args []string) error { + client, defaultNamespace, _, err := clientconfig.ClientAndNamespaceFromContext(cmd.Context()) + if err != nil { + return err + } + + namespace, name, err := templates.ParseTarget(args[0]) + if err != nil { + return err + } + if namespace == "" { + namespace = defaultNamespace + } + + config, err := clientconfig.GetRESTConfig(cmd.Context()) + if err != nil { + return fmt.Errorf("failed to get REST config: %w", err) + } + b.dynamicClient, err = dynamic.NewForConfig(config) + if err != nil { + return fmt.Errorf("failed to create dynamic client: %w", err) + } + + b.stdout = cmd.OutOrStdout() + b.stderr = cmd.ErrOrStderr() + + if err := b.collectResources(cmd.Context(), client, namespace, name); err != nil { + return err + } + + return nil +} + +func (b *DebugBundle) collectResources(ctx context.Context, client kubeclient.Client, namespace, vmName string) error { + if err := b.collectVMResources(ctx, client, namespace, vmName); err != nil { + return fmt.Errorf("failed to collect VM resources: %w", err) + } + + if err := b.collectBlockDevices(ctx, client, namespace, vmName); err != nil { + return fmt.Errorf("failed to collect block devices: %w", err) + } + + if err := b.collectPods(ctx, client, namespace, vmName); err != nil { + return fmt.Errorf("failed to collect pods: %w", err) + } + + return nil +} + +func (b *DebugBundle) handleError(resourceType, resourceName string, err error) bool { + if errors.IsForbidden(err) || errors.IsUnauthorized(err) { + if b.debug { + fmt.Fprintf(b.stderr, "Warning: Skipping %s/%s: permission denied\n", resourceType, resourceName) + } + return true + } + return false +} diff --git a/src/cli/internal/cmd/collectdebuginfo/collectors.go b/src/cli/internal/cmd/collectdebuginfo/collectors.go new file mode 100644 index 0000000000..2168c7087b --- /dev/null +++ b/src/cli/internal/cmd/collectdebuginfo/collectors.go @@ -0,0 +1,457 @@ +/* +Copyright 2025 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 collectdebuginfo + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/yaml" + + "github.com/deckhouse/virtualization/api/client/kubeclient" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +var coreKinds = map[string]bool{ + "Pod": true, + "PersistentVolumeClaim": true, + "PersistentVolume": true, + "Event": true, +} + +// Resource collection functions + +func (b *DebugBundle) collectVMResources(ctx context.Context, client kubeclient.Client, namespace, vmName string) error { + // Get VM + vm, err := client.VirtualMachines(namespace).Get(ctx, vmName, metav1.GetOptions{}) + if err != nil { + if b.handleError("VirtualMachine", vmName, err) { + return nil + } + return err + } + b.outputResource("VirtualMachine", vmName, namespace, vm) + + // Get IVVM + ivvm, err := b.getInternalResource(ctx, "internalvirtualizationvirtualmachines", namespace, vmName) + if err == nil { + b.outputResource("InternalVirtualizationVirtualMachine", vmName, namespace, ivvm) + } else if !b.handleError("InternalVirtualizationVirtualMachine", vmName, err) { + return err + } + + // Get IVVMI + ivvmi, err := b.getInternalResource(ctx, "internalvirtualizationvirtualmachineinstances", namespace, vmName) + if err == nil { + b.outputResource("InternalVirtualizationVirtualMachineInstance", vmName, namespace, ivvmi) + } else if !b.handleError("InternalVirtualizationVirtualMachineInstance", vmName, err) { + return err + } + + // Get VM operations + vmUID := string(vm.UID) + vmops, err := client.VirtualMachineOperations(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("virtualization.deckhouse.io/virtual-machine-uid=%s", vmUID), + }) + if err == nil { + for _, vmop := range vmops.Items { + b.outputResource("VirtualMachineOperation", vmop.Name, namespace, &vmop) + } + } else if !b.handleError("VirtualMachineOperation", "", err) { + return err + } + + // Get migrations + migrations, err := b.getInternalResourceList(ctx, "internalvirtualizationvirtualmachineinstancemigrations", namespace) + if err == nil { + for _, item := range migrations { + vmiName, found, _ := unstructured.NestedString(item.Object, "spec", "vmiName") + if found && vmiName == vmName { + name, _, _ := unstructured.NestedString(item.Object, "metadata", "name") + extraInfo := fmt.Sprintf(" (for VMI: %s)", vmiName) + b.outputResourceWithExtraInfo("InternalVirtualizationVirtualMachineInstanceMigration", name, namespace, item, extraInfo) + } + } + } else if !b.handleError("InternalVirtualizationVirtualMachineInstanceMigration", "", err) { + return err + } + + // Get events for VM + b.collectEvents(ctx, client, namespace, "VirtualMachine", vmName) + + return nil +} + +func (b *DebugBundle) collectBlockDevices(ctx context.Context, client kubeclient.Client, namespace, vmName string) error { + vm, err := client.VirtualMachines(namespace).Get(ctx, vmName, metav1.GetOptions{}) + if err != nil { + return err + } + + // Static block devices + for _, bdRef := range vm.Spec.BlockDeviceRefs { + if err := b.collectBlockDevice(ctx, client, namespace, bdRef.Kind, bdRef.Name); err != nil { + if !b.handleError(string(bdRef.Kind), bdRef.Name, err) { + return err + } + } + } + + // Hotplug block devices + for _, bdRef := range vm.Status.BlockDeviceRefs { + if bdRef.Hotplugged { + if err := b.collectBlockDevice(ctx, client, namespace, bdRef.Kind, bdRef.Name); err != nil { + if !b.handleError(string(bdRef.Kind), bdRef.Name, err) { + return err + } + } + + // Get VMBDA + if bdRef.VirtualMachineBlockDeviceAttachmentName != "" { + vmbda, err := client.VirtualMachineBlockDeviceAttachments(namespace).Get(ctx, bdRef.VirtualMachineBlockDeviceAttachmentName, metav1.GetOptions{}) + if err == nil { + b.outputResource("VirtualMachineBlockDeviceAttachment", vmbda.Name, namespace, vmbda) + b.collectEvents(ctx, client, namespace, "VirtualMachineBlockDeviceAttachment", vmbda.Name) + } else if !b.handleError("VirtualMachineBlockDeviceAttachment", bdRef.VirtualMachineBlockDeviceAttachmentName, err) { + return err + } + } + } + } + + // Get all VMBDA that reference this VM + vmbdas, err := client.VirtualMachineBlockDeviceAttachments(namespace).List(ctx, metav1.ListOptions{}) + if err == nil { + for _, vmbda := range vmbdas.Items { + if vmbda.Spec.VirtualMachineName == vmName { + b.outputResource("VirtualMachineBlockDeviceAttachment", vmbda.Name, namespace, &vmbda) + b.collectEvents(ctx, client, namespace, "VirtualMachineBlockDeviceAttachment", vmbda.Name) + + // Get associated block device + if vmbda.Spec.BlockDeviceRef.Kind != "" && vmbda.Spec.BlockDeviceRef.Name != "" { + // Convert VMBDAObjectRefKind to BlockDeviceKind + var bdKind v1alpha2.BlockDeviceKind + switch vmbda.Spec.BlockDeviceRef.Kind { + case v1alpha2.VMBDAObjectRefKindVirtualDisk: + bdKind = v1alpha2.VirtualDiskKind + case v1alpha2.VMBDAObjectRefKindVirtualImage: + bdKind = v1alpha2.VirtualImageKind + case v1alpha2.VMBDAObjectRefKindClusterVirtualImage: + bdKind = v1alpha2.ClusterVirtualImageKind + default: + continue + } + if err := b.collectBlockDevice(ctx, client, namespace, bdKind, vmbda.Spec.BlockDeviceRef.Name); err != nil { + if !b.handleError(string(bdKind), vmbda.Spec.BlockDeviceRef.Name, err) { + return err + } + } + } + } + } + } else if !b.handleError("VirtualMachineBlockDeviceAttachment", "", err) { + return err + } + + return nil +} + +func (b *DebugBundle) collectBlockDevice(ctx context.Context, client kubeclient.Client, namespace string, kind v1alpha2.BlockDeviceKind, name string) error { + switch kind { + case v1alpha2.VirtualDiskKind: + vd, err := client.VirtualDisks(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + b.outputResource("VirtualDisk", name, namespace, vd) + b.collectEvents(ctx, client, namespace, "VirtualDisk", name) + + // Get PVC + if vd.Status.Target.PersistentVolumeClaim != "" { + pvc, err := client.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, vd.Status.Target.PersistentVolumeClaim, metav1.GetOptions{}) + if err == nil { + b.outputResource("PersistentVolumeClaim", pvc.Name, namespace, pvc) + b.collectEvents(ctx, client, namespace, "PersistentVolumeClaim", pvc.Name) + + // Get PV + if pvc.Spec.VolumeName != "" { + pv, err := client.CoreV1().PersistentVolumes().Get(ctx, pvc.Spec.VolumeName, metav1.GetOptions{}) + if err == nil { + b.outputResource("PersistentVolume", pv.Name, "", pv) + } else if !b.handleError("PersistentVolume", pvc.Spec.VolumeName, err) { + return err + } + } + } else if !b.handleError("PersistentVolumeClaim", vd.Status.Target.PersistentVolumeClaim, err) { + return err + } + } + + case v1alpha2.VirtualImageKind: + vi, err := client.VirtualImages(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + b.outputResource("VirtualImage", name, namespace, vi) + b.collectEvents(ctx, client, namespace, "VirtualImage", name) + + case v1alpha2.ClusterVirtualImageKind: + cvi, err := client.ClusterVirtualImages().Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + b.outputResource("ClusterVirtualImage", name, "", cvi) + + default: + return fmt.Errorf("unknown block device kind: %s", kind) + } + + return nil +} + +func (b *DebugBundle) collectPods(ctx context.Context, client kubeclient.Client, namespace, vmName string) error { + pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("vm.kubevirt.internal.virtualization.deckhouse.io/name=%s", vmName), + }) + if err != nil { + if b.handleError("Pod", "", err) { + return nil + } + return err + } + + // Collect VM pods and their UIDs for finding dependent pods + vmPodUIDs := make(map[string]bool) + for _, pod := range pods.Items { + vmPodUIDs[string(pod.UID)] = true + b.outputResource("Pod", pod.Name, namespace, &pod) + b.collectEvents(ctx, client, namespace, "Pod", pod.Name) + + if b.saveLogs { + b.collectSinglePodLogs(ctx, client, namespace, pod.Name) + } + } + + // Collect pods that have ownerReference to VM pods (e.g., hotplug volume pods) + if len(vmPodUIDs) > 0 { + allPods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + // If we can't list all pods, continue without dependent pods + if !b.handleError("Pod", namespace, err) { + return err + } + } else { + for _, pod := range allPods.Items { + // Skip VM pods we already collected + if vmPodUIDs[string(pod.UID)] { + continue + } + // Check if this pod has ownerReference to any VM pod + for _, ownerRef := range pod.OwnerReferences { + if ownerRef.Kind == "Pod" && vmPodUIDs[string(ownerRef.UID)] { + b.outputResource("Pod", pod.Name, namespace, &pod) + b.collectEvents(ctx, client, namespace, "Pod", pod.Name) + if b.saveLogs { + b.collectSinglePodLogs(ctx, client, namespace, pod.Name) + } + break + } + } + } + } + } + + return nil +} + +// Event collection functions + +func (b *DebugBundle) collectEvents(ctx context.Context, client kubeclient.Client, namespace, resourceType, resourceName string) { + events, err := client.CoreV1().Events(namespace).List(ctx, metav1.ListOptions{ + FieldSelector: fmt.Sprintf("involvedObject.name=%s", resourceName), + }) + if err != nil { + if b.handleError("Event", resourceName, err) { + return + } + return + } + + // Add each event individually to preserve TypeMeta + for i := range events.Items { + b.outputResource("Event", fmt.Sprintf("%s-%s-%d", strings.ToLower(resourceType), resourceName, i), namespace, &events.Items[i]) + } +} + +// Log collection functions + +const ( + // logReadTimeout is the maximum time to wait for reading pod logs + logReadTimeout = 30 * time.Second + // maxLogLines limits the number of log lines to prevent hanging on very large logs + maxLogLines = int64(10000) +) + +func (b *DebugBundle) collectSinglePodLogs(ctx context.Context, client kubeclient.Client, namespace, podName string) { + logPrefix := fmt.Sprintf("logs %s/%s", namespace, podName) + tailLines := maxLogLines + + // Get current logs with timeout and line limit + logCtx, cancel := context.WithTimeout(ctx, logReadTimeout) + defer cancel() + + req := client.CoreV1().Pods(namespace).GetLogs(podName, &corev1.PodLogOptions{ + TailLines: &tailLines, + }) + logStream, err := req.Stream(logCtx) + if err == nil { + defer logStream.Close() + logContent, err := b.readLogsWithTimeout(logCtx, logStream) + if err == nil { + fmt.Fprintf(b.stdout, "\n# %s\n", logPrefix) + fmt.Fprintf(b.stdout, "%s\n", string(logContent)) + } + } + + // Get previous logs with timeout and line limit + logCtx, cancel = context.WithTimeout(ctx, logReadTimeout) + defer cancel() + + req = client.CoreV1().Pods(namespace).GetLogs(podName, &corev1.PodLogOptions{ + Previous: true, + TailLines: &tailLines, + }) + logStream, err = req.Stream(logCtx) + if err == nil { + defer logStream.Close() + logContent, err := b.readLogsWithTimeout(logCtx, logStream) + if err == nil { + fmt.Fprintf(b.stdout, "\n# %s (previous)\n", logPrefix) + fmt.Fprintf(b.stdout, "%s\n", string(logContent)) + } + } +} + +// readLogsWithTimeout reads logs from stream with timeout protection +func (b *DebugBundle) readLogsWithTimeout(ctx context.Context, stream io.ReadCloser) ([]byte, error) { + type result struct { + data []byte + err error + } + resultChan := make(chan result, 1) + + go func() { + data, err := io.ReadAll(stream) + resultChan <- result{data: data, err: err} + }() + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case res := <-resultChan: + return res.data, res.err + } +} + +// Helper functions + +func (b *DebugBundle) getInternalResource(ctx context.Context, resource, namespace, name string) (*unstructured.Unstructured, error) { + obj, err := b.dynamicClient.Resource(schema.GroupVersionResource{ + Group: "internal.virtualization.deckhouse.io", + Version: "v1", + Resource: resource, + }).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, err + } + return obj, nil +} + +func (b *DebugBundle) getInternalResourceList(ctx context.Context, resource, namespace string) ([]*unstructured.Unstructured, error) { + list, err := b.dynamicClient.Resource(schema.GroupVersionResource{ + Group: "internal.virtualization.deckhouse.io", + Version: "v1", + Resource: resource, + }).Namespace(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + + result := make([]*unstructured.Unstructured, len(list.Items)) + for i := range list.Items { + result[i] = &list.Items[i] + } + return result, nil +} + +func (b *DebugBundle) outputResource(kind, name, namespace string, obj runtime.Object) error { + return b.outputResourceWithExtraInfo(kind, name, namespace, obj, "") +} + +func (b *DebugBundle) outputResourceWithExtraInfo(kind, name, namespace string, obj runtime.Object, extraInfo string) error { + // Output separator if not first resource + if b.resourceCount > 0 { + fmt.Fprintf(b.stdout, "\n---\n") + } + b.resourceCount++ + + // Ensure Kind is set from input if missing + gvk := obj.GetObjectKind().GroupVersionKind() + if gvk.Kind == "" { + gvk.Kind = kind + obj.GetObjectKind().SetGroupVersionKind(gvk) + } + + // If GroupVersion is missing/empty, try to get from scheme + if gvk.GroupVersion().Empty() { + gvks, _, err := kubeclient.Scheme.ObjectKinds(obj) + if err == nil && len(gvks) > 0 { + gvk = gvks[0] + obj.GetObjectKind().SetGroupVersionKind(gvk) + } else if coreKinds[kind] { + // Fallback for core Kubernetes resources if scheme doesn't know about them + gvk = schema.GroupVersionKind{Group: "", Version: "v1", Kind: kind} + obj.GetObjectKind().SetGroupVersionKind(gvk) + } + } + + // Marshal to JSON (now with TypeMeta if set) + jsonBytes, err := json.Marshal(obj) + if err != nil { + return fmt.Errorf("failed to marshal %s/%s to JSON: %w", kind, name, err) + } + + // Convert to YAML + yamlBytes, err := yaml.JSONToYAML(jsonBytes) + if err != nil { + return fmt.Errorf("failed to convert %s/%s to YAML: %w", kind, name, err) + } + + // Output with optional extra info + fmt.Fprintf(b.stdout, "# %d. %s: %s%s\n%s", b.resourceCount, kind, name, extraInfo, string(yamlBytes)) + + return nil +} diff --git a/src/cli/pkg/command/virtualization.go b/src/cli/pkg/command/virtualization.go index b6ffec1591..7aece0ccbb 100644 --- a/src/cli/pkg/command/virtualization.go +++ b/src/cli/pkg/command/virtualization.go @@ -32,6 +32,7 @@ import ( "github.com/deckhouse/virtualization/api/client/kubeclient" "github.com/deckhouse/virtualization/src/cli/internal/clientconfig" "github.com/deckhouse/virtualization/src/cli/internal/cmd/ansibleinventory" + "github.com/deckhouse/virtualization/src/cli/internal/cmd/collectdebuginfo" "github.com/deckhouse/virtualization/src/cli/internal/cmd/console" "github.com/deckhouse/virtualization/src/cli/internal/cmd/lifecycle" "github.com/deckhouse/virtualization/src/cli/internal/cmd/portforward" @@ -86,6 +87,7 @@ func NewCommand(programName string) *cobra.Command { virtCmd.AddCommand( ansibleinventory.NewCommand(), console.NewCommand(), + collectdebuginfo.NewCommand(), vnc.NewCommand(), portforward.NewCommand(), ssh.NewCommand(), From 345d2141fa991d2b174c76b123876ac4349d47f0 Mon Sep 17 00:00:00 2001 From: Pavel Tishkov Date: Fri, 19 Dec 2025 15:23:06 +0300 Subject: [PATCH 2/4] upd Signed-off-by: Pavel Tishkov --- .../internal/cmd/collectdebuginfo/collectdebuginfo.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/cli/internal/cmd/collectdebuginfo/collectdebuginfo.go b/src/cli/internal/cmd/collectdebuginfo/collectdebuginfo.go index 680b5ef0c1..67c2ce7d6c 100644 --- a/src/cli/internal/cmd/collectdebuginfo/collectdebuginfo.go +++ b/src/cli/internal/cmd/collectdebuginfo/collectdebuginfo.go @@ -34,7 +34,7 @@ func NewCommand() *cobra.Command { bundle := &DebugBundle{} cmd := &cobra.Command{ Use: "collect-debug-info (VirtualMachine)", - Short: "Collect debug information for VM: configuration, events, and logs.", + Short: "Collect debug information for VM: configuration, events, and logs. Output is written to stdout.", Example: usage(), Args: templates.ExactArgs("collect-debug-info", 1), RunE: bundle.Run, @@ -56,12 +56,16 @@ type DebugBundle struct { } func usage() string { - return ` # Collect debug info for VirtualMachine 'myvm': + return ` # Collect debug info for VirtualMachine 'myvm' (output to stdout): {{ProgramName}} collect-debug-info myvm {{ProgramName}} collect-debug-info myvm.mynamespace {{ProgramName}} collect-debug-info myvm -n mynamespace + # Include pod logs: - {{ProgramName}} collect-debug-info --with-logs myvm` + {{ProgramName}} collect-debug-info --with-logs myvm + + # Save compressed output to file: + {{ProgramName}} collect-debug-info --with-logs myvm | gzip > debug-info.yaml.gz` } func (b *DebugBundle) Run(cmd *cobra.Command, args []string) error { From 2583be17e0282336526e89e34409c4b055c8ad79 Mon Sep 17 00:00:00 2001 From: Pavel Tishkov Date: Sat, 20 Dec 2025 13:14:08 +0300 Subject: [PATCH 3/4] fix linter issues Signed-off-by: Pavel Tishkov --- .../cmd/collectdebuginfo/collectdebuginfo.go | 2 +- .../cmd/collectdebuginfo/collectors.go | 95 +++++++++++++------ 2 files changed, 69 insertions(+), 28 deletions(-) diff --git a/src/cli/internal/cmd/collectdebuginfo/collectdebuginfo.go b/src/cli/internal/cmd/collectdebuginfo/collectdebuginfo.go index 67c2ce7d6c..389f7074db 100644 --- a/src/cli/internal/cmd/collectdebuginfo/collectdebuginfo.go +++ b/src/cli/internal/cmd/collectdebuginfo/collectdebuginfo.go @@ -120,7 +120,7 @@ func (b *DebugBundle) collectResources(ctx context.Context, client kubeclient.Cl func (b *DebugBundle) handleError(resourceType, resourceName string, err error) bool { if errors.IsForbidden(err) || errors.IsUnauthorized(err) { if b.debug { - fmt.Fprintf(b.stderr, "Warning: Skipping %s/%s: permission denied\n", resourceType, resourceName) + _, _ = fmt.Fprintf(b.stderr, "Warning: Skipping %s/%s: permission denied\n", resourceType, resourceName) } return true } diff --git a/src/cli/internal/cmd/collectdebuginfo/collectors.go b/src/cli/internal/cmd/collectdebuginfo/collectors.go index 2168c7087b..d6aee9625e 100644 --- a/src/cli/internal/cmd/collectdebuginfo/collectors.go +++ b/src/cli/internal/cmd/collectdebuginfo/collectors.go @@ -53,12 +53,16 @@ func (b *DebugBundle) collectVMResources(ctx context.Context, client kubeclient. } return err } - b.outputResource("VirtualMachine", vmName, namespace, vm) + if err := b.outputResource("VirtualMachine", vmName, namespace, vm); err != nil { + return fmt.Errorf("failed to output VirtualMachine: %w", err) + } // Get IVVM ivvm, err := b.getInternalResource(ctx, "internalvirtualizationvirtualmachines", namespace, vmName) if err == nil { - b.outputResource("InternalVirtualizationVirtualMachine", vmName, namespace, ivvm) + if err := b.outputResource("InternalVirtualizationVirtualMachine", vmName, namespace, ivvm); err != nil { + return fmt.Errorf("failed to output InternalVirtualizationVirtualMachine: %w", err) + } } else if !b.handleError("InternalVirtualizationVirtualMachine", vmName, err) { return err } @@ -66,7 +70,9 @@ func (b *DebugBundle) collectVMResources(ctx context.Context, client kubeclient. // Get IVVMI ivvmi, err := b.getInternalResource(ctx, "internalvirtualizationvirtualmachineinstances", namespace, vmName) if err == nil { - b.outputResource("InternalVirtualizationVirtualMachineInstance", vmName, namespace, ivvmi) + if err := b.outputResource("InternalVirtualizationVirtualMachineInstance", vmName, namespace, ivvmi); err != nil { + return fmt.Errorf("failed to output InternalVirtualizationVirtualMachineInstance: %w", err) + } } else if !b.handleError("InternalVirtualizationVirtualMachineInstance", vmName, err) { return err } @@ -78,7 +84,9 @@ func (b *DebugBundle) collectVMResources(ctx context.Context, client kubeclient. }) if err == nil { for _, vmop := range vmops.Items { - b.outputResource("VirtualMachineOperation", vmop.Name, namespace, &vmop) + if err := b.outputResource("VirtualMachineOperation", vmop.Name, namespace, &vmop); err != nil { + return fmt.Errorf("failed to output VirtualMachineOperation: %w", err) + } } } else if !b.handleError("VirtualMachineOperation", "", err) { return err @@ -92,7 +100,9 @@ func (b *DebugBundle) collectVMResources(ctx context.Context, client kubeclient. if found && vmiName == vmName { name, _, _ := unstructured.NestedString(item.Object, "metadata", "name") extraInfo := fmt.Sprintf(" (for VMI: %s)", vmiName) - b.outputResourceWithExtraInfo("InternalVirtualizationVirtualMachineInstanceMigration", name, namespace, item, extraInfo) + if err := b.outputResourceWithExtraInfo("InternalVirtualizationVirtualMachineInstanceMigration", name, namespace, item, extraInfo); err != nil { + return fmt.Errorf("failed to output InternalVirtualizationVirtualMachineInstanceMigration: %w", err) + } } } } else if !b.handleError("InternalVirtualizationVirtualMachineInstanceMigration", "", err) { @@ -133,7 +143,9 @@ func (b *DebugBundle) collectBlockDevices(ctx context.Context, client kubeclient if bdRef.VirtualMachineBlockDeviceAttachmentName != "" { vmbda, err := client.VirtualMachineBlockDeviceAttachments(namespace).Get(ctx, bdRef.VirtualMachineBlockDeviceAttachmentName, metav1.GetOptions{}) if err == nil { - b.outputResource("VirtualMachineBlockDeviceAttachment", vmbda.Name, namespace, vmbda) + if err := b.outputResource("VirtualMachineBlockDeviceAttachment", vmbda.Name, namespace, vmbda); err != nil { + return fmt.Errorf("failed to output VirtualMachineBlockDeviceAttachment: %w", err) + } b.collectEvents(ctx, client, namespace, "VirtualMachineBlockDeviceAttachment", vmbda.Name) } else if !b.handleError("VirtualMachineBlockDeviceAttachment", bdRef.VirtualMachineBlockDeviceAttachmentName, err) { return err @@ -147,7 +159,9 @@ func (b *DebugBundle) collectBlockDevices(ctx context.Context, client kubeclient if err == nil { for _, vmbda := range vmbdas.Items { if vmbda.Spec.VirtualMachineName == vmName { - b.outputResource("VirtualMachineBlockDeviceAttachment", vmbda.Name, namespace, &vmbda) + if err := b.outputResource("VirtualMachineBlockDeviceAttachment", vmbda.Name, namespace, &vmbda); err != nil { + return fmt.Errorf("failed to output VirtualMachineBlockDeviceAttachment: %w", err) + } b.collectEvents(ctx, client, namespace, "VirtualMachineBlockDeviceAttachment", vmbda.Name) // Get associated block device @@ -186,21 +200,27 @@ func (b *DebugBundle) collectBlockDevice(ctx context.Context, client kubeclient. if err != nil { return err } - b.outputResource("VirtualDisk", name, namespace, vd) + if err := b.outputResource("VirtualDisk", name, namespace, vd); err != nil { + return fmt.Errorf("failed to output VirtualDisk: %w", err) + } b.collectEvents(ctx, client, namespace, "VirtualDisk", name) // Get PVC if vd.Status.Target.PersistentVolumeClaim != "" { pvc, err := client.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, vd.Status.Target.PersistentVolumeClaim, metav1.GetOptions{}) if err == nil { - b.outputResource("PersistentVolumeClaim", pvc.Name, namespace, pvc) + if err := b.outputResource("PersistentVolumeClaim", pvc.Name, namespace, pvc); err != nil { + return fmt.Errorf("failed to output PersistentVolumeClaim: %w", err) + } b.collectEvents(ctx, client, namespace, "PersistentVolumeClaim", pvc.Name) // Get PV if pvc.Spec.VolumeName != "" { pv, err := client.CoreV1().PersistentVolumes().Get(ctx, pvc.Spec.VolumeName, metav1.GetOptions{}) if err == nil { - b.outputResource("PersistentVolume", pv.Name, "", pv) + if err := b.outputResource("PersistentVolume", pv.Name, "", pv); err != nil { + return fmt.Errorf("failed to output PersistentVolume: %w", err) + } } else if !b.handleError("PersistentVolume", pvc.Spec.VolumeName, err) { return err } @@ -215,7 +235,9 @@ func (b *DebugBundle) collectBlockDevice(ctx context.Context, client kubeclient. if err != nil { return err } - b.outputResource("VirtualImage", name, namespace, vi) + if err := b.outputResource("VirtualImage", name, namespace, vi); err != nil { + return fmt.Errorf("failed to output VirtualImage: %w", err) + } b.collectEvents(ctx, client, namespace, "VirtualImage", name) case v1alpha2.ClusterVirtualImageKind: @@ -223,7 +245,9 @@ func (b *DebugBundle) collectBlockDevice(ctx context.Context, client kubeclient. if err != nil { return err } - b.outputResource("ClusterVirtualImage", name, "", cvi) + if err := b.outputResource("ClusterVirtualImage", name, "", cvi); err != nil { + return fmt.Errorf("failed to output ClusterVirtualImage: %w", err) + } default: return fmt.Errorf("unknown block device kind: %s", kind) @@ -247,7 +271,9 @@ func (b *DebugBundle) collectPods(ctx context.Context, client kubeclient.Client, vmPodUIDs := make(map[string]bool) for _, pod := range pods.Items { vmPodUIDs[string(pod.UID)] = true - b.outputResource("Pod", pod.Name, namespace, &pod) + if err := b.outputResource("Pod", pod.Name, namespace, &pod); err != nil { + return fmt.Errorf("failed to output Pod: %w", err) + } b.collectEvents(ctx, client, namespace, "Pod", pod.Name) if b.saveLogs { @@ -272,7 +298,9 @@ func (b *DebugBundle) collectPods(ctx context.Context, client kubeclient.Client, // Check if this pod has ownerReference to any VM pod for _, ownerRef := range pod.OwnerReferences { if ownerRef.Kind == "Pod" && vmPodUIDs[string(ownerRef.UID)] { - b.outputResource("Pod", pod.Name, namespace, &pod) + if err := b.outputResource("Pod", pod.Name, namespace, &pod); err != nil { + return fmt.Errorf("failed to output Pod: %w", err) + } b.collectEvents(ctx, client, namespace, "Pod", pod.Name) if b.saveLogs { b.collectSinglePodLogs(ctx, client, namespace, pod.Name) @@ -302,7 +330,10 @@ func (b *DebugBundle) collectEvents(ctx context.Context, client kubeclient.Clien // Add each event individually to preserve TypeMeta for i := range events.Items { - b.outputResource("Event", fmt.Sprintf("%s-%s-%d", strings.ToLower(resourceType), resourceName, i), namespace, &events.Items[i]) + if err := b.outputResource("Event", fmt.Sprintf("%s-%s-%d", strings.ToLower(resourceType), resourceName, i), namespace, &events.Items[i]); err != nil { + // Log error but continue processing other events + _, _ = fmt.Fprintf(b.stderr, "Warning: failed to output Event: %v\n", err) + } } } @@ -327,12 +358,17 @@ func (b *DebugBundle) collectSinglePodLogs(ctx context.Context, client kubeclien TailLines: &tailLines, }) logStream, err := req.Stream(logCtx) - if err == nil { - defer logStream.Close() + if err == nil && logStream != nil { + stream := logStream // Capture in closure + defer func() { + if stream != nil { + _ = stream.Close() + } + }() logContent, err := b.readLogsWithTimeout(logCtx, logStream) if err == nil { - fmt.Fprintf(b.stdout, "\n# %s\n", logPrefix) - fmt.Fprintf(b.stdout, "%s\n", string(logContent)) + _, _ = fmt.Fprintf(b.stdout, "\n# %s\n", logPrefix) + _, _ = fmt.Fprintf(b.stdout, "%s\n", string(logContent)) } } @@ -345,12 +381,17 @@ func (b *DebugBundle) collectSinglePodLogs(ctx context.Context, client kubeclien TailLines: &tailLines, }) logStream, err = req.Stream(logCtx) - if err == nil { - defer logStream.Close() + if err == nil && logStream != nil { + stream := logStream // Capture in closure + defer func() { + if stream != nil { + _ = stream.Close() + } + }() logContent, err := b.readLogsWithTimeout(logCtx, logStream) if err == nil { - fmt.Fprintf(b.stdout, "\n# %s (previous)\n", logPrefix) - fmt.Fprintf(b.stdout, "%s\n", string(logContent)) + _, _ = fmt.Fprintf(b.stdout, "\n# %s (previous)\n", logPrefix) + _, _ = fmt.Fprintf(b.stdout, "%s\n", string(logContent)) } } } @@ -414,7 +455,7 @@ func (b *DebugBundle) outputResource(kind, name, namespace string, obj runtime.O func (b *DebugBundle) outputResourceWithExtraInfo(kind, name, namespace string, obj runtime.Object, extraInfo string) error { // Output separator if not first resource if b.resourceCount > 0 { - fmt.Fprintf(b.stdout, "\n---\n") + _, _ = fmt.Fprintf(b.stdout, "\n---\n") } b.resourceCount++ @@ -441,17 +482,17 @@ func (b *DebugBundle) outputResourceWithExtraInfo(kind, name, namespace string, // Marshal to JSON (now with TypeMeta if set) jsonBytes, err := json.Marshal(obj) if err != nil { - return fmt.Errorf("failed to marshal %s/%s to JSON: %w", kind, name, err) + return fmt.Errorf("failed to marshal %s/%s (namespace: %s) to JSON: %w", kind, name, namespace, err) } // Convert to YAML yamlBytes, err := yaml.JSONToYAML(jsonBytes) if err != nil { - return fmt.Errorf("failed to convert %s/%s to YAML: %w", kind, name, err) + return fmt.Errorf("failed to convert %s/%s (namespace: %s) to YAML: %w", kind, name, namespace, err) } // Output with optional extra info - fmt.Fprintf(b.stdout, "# %d. %s: %s%s\n%s", b.resourceCount, kind, name, extraInfo, string(yamlBytes)) + _, _ = fmt.Fprintf(b.stdout, "# %d. %s: %s%s\n%s", b.resourceCount, kind, name, extraInfo, string(yamlBytes)) return nil } From de06049a43b51c8f3641d49e662f7e22a1e7eabc Mon Sep 17 00:00:00 2001 From: Pavel Tishkov Date: Sat, 20 Dec 2025 13:53:45 +0300 Subject: [PATCH 4/4] fix logicx Signed-off-by: Pavel Tishkov --- .../cmd/collectdebuginfo/collectors.go | 50 +++++++++---------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/src/cli/internal/cmd/collectdebuginfo/collectors.go b/src/cli/internal/cmd/collectdebuginfo/collectors.go index d6aee9625e..11447a5d55 100644 --- a/src/cli/internal/cmd/collectdebuginfo/collectors.go +++ b/src/cli/internal/cmd/collectdebuginfo/collectors.go @@ -121,40 +121,33 @@ func (b *DebugBundle) collectBlockDevices(ctx context.Context, client kubeclient return err } - // Static block devices - for _, bdRef := range vm.Spec.BlockDeviceRefs { - if err := b.collectBlockDevice(ctx, client, namespace, bdRef.Kind, bdRef.Name); err != nil { - if !b.handleError(string(bdRef.Kind), bdRef.Name, err) { - return err - } - } + // Track collected block devices to avoid duplicates + collectedBlockDevices := make(map[string]bool) + + // Helper function to create block device key + bdKey := func(kind v1alpha2.BlockDeviceKind, name string) string { + return string(kind) + ":" + name } - // Hotplug block devices - for _, bdRef := range vm.Status.BlockDeviceRefs { - if bdRef.Hotplugged { + // Static block devices + // Note: blockDeviceRefs can only contain block devices (VirtualDisk, VirtualImage, ClusterVirtualImage), + // not VMBDA. VMBDA are collected separately below. + for _, bdRef := range vm.Spec.BlockDeviceRefs { + key := bdKey(bdRef.Kind, bdRef.Name) + if !collectedBlockDevices[key] { if err := b.collectBlockDevice(ctx, client, namespace, bdRef.Kind, bdRef.Name); err != nil { if !b.handleError(string(bdRef.Kind), bdRef.Name, err) { return err } - } - - // Get VMBDA - if bdRef.VirtualMachineBlockDeviceAttachmentName != "" { - vmbda, err := client.VirtualMachineBlockDeviceAttachments(namespace).Get(ctx, bdRef.VirtualMachineBlockDeviceAttachmentName, metav1.GetOptions{}) - if err == nil { - if err := b.outputResource("VirtualMachineBlockDeviceAttachment", vmbda.Name, namespace, vmbda); err != nil { - return fmt.Errorf("failed to output VirtualMachineBlockDeviceAttachment: %w", err) - } - b.collectEvents(ctx, client, namespace, "VirtualMachineBlockDeviceAttachment", vmbda.Name) - } else if !b.handleError("VirtualMachineBlockDeviceAttachment", bdRef.VirtualMachineBlockDeviceAttachmentName, err) { - return err - } + } else { + collectedBlockDevices[key] = true } } } // Get all VMBDA that reference this VM + // Note: Hotplugged block devices are collected through VMBDA, not from vm.Status.BlockDeviceRefs, + // to avoid duplication. All hotplugged devices have corresponding VMBDA resources. vmbdas, err := client.VirtualMachineBlockDeviceAttachments(namespace).List(ctx, metav1.ListOptions{}) if err == nil { for _, vmbda := range vmbdas.Items { @@ -178,9 +171,14 @@ func (b *DebugBundle) collectBlockDevices(ctx context.Context, client kubeclient default: continue } - if err := b.collectBlockDevice(ctx, client, namespace, bdKind, vmbda.Spec.BlockDeviceRef.Name); err != nil { - if !b.handleError(string(bdKind), vmbda.Spec.BlockDeviceRef.Name, err) { - return err + key := bdKey(bdKind, vmbda.Spec.BlockDeviceRef.Name) + if !collectedBlockDevices[key] { + if err := b.collectBlockDevice(ctx, client, namespace, bdKind, vmbda.Spec.BlockDeviceRef.Name); err != nil { + if !b.handleError(string(bdKind), vmbda.Spec.BlockDeviceRef.Name, err) { + return err + } + } else { + collectedBlockDevices[key] = true } } }