Files
troubleshoot/pkg/collect/exec.go
Ethan Mosbaugh 4c6af55e7c fix: correct RBAC verb for pods/exec from get to create (#2037)
fix: correct RBAC verb and WebSocket fallback for pods/exec

This commit fixes three related issues that prevented exec collectors from
working with minimal RBAC permissions:

1. RBAC preflight check used wrong verb for pods/exec
   Changed from "get" to "create" in v1beta1 and v1beta2 AccessReviewSpecs.
   The pods/exec subresource requires "create" to execute commands.

2. WebSocket fallback used wrong httpstream package import
   The fallback executor checked IsUpgradeFailure using the apimachinery
   httpstream package, but the roundtripper creates UpgradeFailureError using
   the streaming httpstream package. These are different Go types, so
   errors.As always returned false and fallback to SPDY never triggered.
   Changed import to k8s.io/streaming/pkg/httpstream.

3. Stdin mismatch caused SPDY fallback to hang
   PodExecOptions always set Stdin:true but StreamOptions always passed
   Stdin:nil. When WebSocket failed and fell back to SPDY, the server
   waited for stdin data that never arrived. Changed Stdin to false in
   PodExecOptions for exec, copy, and copy_from_host collectors.
2026-05-01 13:03:42 -07:00

176 lines
4.8 KiB
Go

package collect
import (
"bytes"
"context"
"fmt"
"path/filepath"
"time"
"github.com/pkg/errors"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"github.com/replicatedhq/troubleshoot/pkg/k8sutil"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/remotecommand"
)
type CollectExec struct {
Collector *troubleshootv1beta2.Exec
BundlePath string
Namespace string
ClientConfig *rest.Config
Client kubernetes.Interface
Context context.Context
RBACErrors
}
func (c *CollectExec) Title() string {
return getCollectorName(c)
}
func (c *CollectExec) IsExcluded() (bool, error) {
return isExcluded(c.Collector.Exclude)
}
func (c *CollectExec) Collect(progressChan chan<- interface{}) (CollectorResult, error) {
if c.Collector.Timeout == "" {
return execWithoutTimeout(c.ClientConfig, c.BundlePath, c.Collector)
}
timeout, err := time.ParseDuration(c.Collector.Timeout)
if err != nil {
return nil, err
}
errCh := make(chan error, 1)
resultCh := make(chan CollectorResult, 1)
// TODO: Use a context with timeout instead of a goroutine
go func() {
b, err := execWithoutTimeout(c.ClientConfig, c.BundlePath, c.Collector)
if err != nil {
errCh <- err
} else {
resultCh <- b
}
}()
select {
case <-time.After(timeout):
return nil, errors.New("timeout")
case result := <-resultCh:
return result, nil
case err := <-errCh:
return nil, err
}
}
func execWithoutTimeout(clientConfig *rest.Config, bundlePath string, execCollector *troubleshootv1beta2.Exec) (CollectorResult, error) {
client, err := kubernetes.NewForConfig(clientConfig)
if err != nil {
return nil, err
}
output := NewResult()
ctx := context.Background()
pods, podsErrors := listPodsInSelectors(ctx, client, execCollector.Namespace, execCollector.Selector)
if len(podsErrors) > 0 {
output.SaveResult(bundlePath, getExecErrorsFileName(execCollector), marshalErrors(podsErrors))
}
if len(pods) > 0 {
// When the selector refers to more than one replica of a pod, the exec collector will execute in only one of the pods
pod := pods[0]
stdout, stderr, execErrors := getExecOutputs(ctx, clientConfig, client, pod, execCollector)
container := pod.Spec.Containers[0].Name
if execCollector.ContainerName != "" {
container = execCollector.ContainerName
}
filePrefix := execCollector.CollectorName
if filePrefix == "" {
filePrefix = container
}
path := filepath.Join(execCollector.Name, pod.Namespace, pod.Name)
if len(stdout) > 0 {
output.SaveResult(bundlePath, filepath.Join(path, filePrefix+"-stdout.txt"), bytes.NewBuffer(stdout))
}
if len(stderr) > 0 {
output.SaveResult(bundlePath, filepath.Join(path, filePrefix+"-stderr.txt"), bytes.NewBuffer(stderr))
}
if len(execErrors) > 0 {
output.SaveResult(bundlePath, filepath.Join(path, filePrefix+"-errors.json"), marshalErrors(execErrors))
}
}
return output, nil
}
func getExecOutputs(
ctx context.Context, clientConfig *rest.Config, client *kubernetes.Clientset, pod corev1.Pod, execCollector *troubleshootv1beta2.Exec,
) ([]byte, []byte, []string) {
container := pod.Spec.Containers[0].Name
if execCollector.ContainerName != "" {
container = execCollector.ContainerName
}
req := client.CoreV1().RESTClient().Post().Resource("pods").Name(pod.Name).Namespace(pod.Namespace).SubResource("exec")
scheme := runtime.NewScheme()
if err := corev1.AddToScheme(scheme); err != nil {
return nil, nil, []string{err.Error()}
}
parameterCodec := runtime.NewParameterCodec(scheme)
// Stdin must be false because StreamOptions.Stdin is nil below.
// A mismatch causes the SPDY fallback (after WebSocket fails on RBAC)
// to hang: the API server opens a stdin stream but never receives EOF.
req.VersionedParams(&corev1.PodExecOptions{
Command: append(execCollector.Command, execCollector.Args...),
Container: container,
Stdin: false,
Stdout: true,
Stderr: true,
TTY: false,
}, parameterCodec)
exec, err := k8sutil.NewFallbackExecutor(clientConfig, req.URL())
if err != nil {
return nil, nil, []string{err.Error()}
}
stdout := new(bytes.Buffer)
stderr := new(bytes.Buffer)
err = exec.StreamWithContext(ctx, remotecommand.StreamOptions{
Stdin: nil,
Stdout: stdout,
Stderr: stderr,
Tty: false,
})
if err != nil {
return stdout.Bytes(), stderr.Bytes(), []string{err.Error()}
}
return stdout.Bytes(), stderr.Bytes(), nil
}
func getExecErrorsFileName(execCollector *troubleshootv1beta2.Exec) string {
if len(execCollector.Name) > 0 {
return fmt.Sprintf("%s-errors.json", execCollector.Name)
}
if len(execCollector.CollectorName) > 0 {
return fmt.Sprintf("%s-errors.json", execCollector.CollectorName)
}
// TODO: random part
return "errors.json"
}