mirror of
https://github.com/replicatedhq/troubleshoot.git
synced 2026-09-03 00:47:17 +00:00
* docs: add design spec for SPDY executor removal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add implementation plan for SPDY executor removal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(k8sutil): add NewFallbackExecutor helper * fix(k8sutil): rename url param to u to avoid shadowing net/url * refactor(collect): use fallback executor in exec collector * refactor(collect): use fallback executor in copy collector * refactor(collect): use fallback executor in copy_from_host collector * refactor(collect): use fallback executor in sonobuoy_results collector * refactor(collect): use fallback executor in etcd collector * refactor(collect): use fallback executor in longhorn collector * refactor(supportbundle): use fallback executor in collect * chore(k8sutil): delete unused PortForward function * fix(k8sutil): use GET for WebSocket executor and broaden fallback predicate WebSocket upgrade requires GET per RFC 6455. Also add IsHTTPSProxyError to the fallback predicate so HTTPS proxy environments fall back to SPDY correctly, matching kubectl's implementation. Remove method param from NewFallbackExecutor since the methods are now transport-specific and not caller-controlled. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: remove superpowers docs artifacts * fix(collect): enable stdout capture in exec collector * fix(collect): enable stdout capture in copy collectors PodExecOptions.Stdout: false causes the WebSocket API server to discard stdout entirely, silently breaking tar output for file copy operations. With SPDY this mismatch was harmless; WebSocket strictly respects the field. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
397 lines
11 KiB
Go
397 lines
11 KiB
Go
package collect
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"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"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/client-go/kubernetes"
|
|
"k8s.io/client-go/kubernetes/scheme"
|
|
"k8s.io/client-go/rest"
|
|
"k8s.io/client-go/tools/remotecommand"
|
|
"k8s.io/klog/v2"
|
|
)
|
|
|
|
const etcdOutputDir = "etcd"
|
|
|
|
type CollectEtcd struct {
|
|
Collector *troubleshootv1beta2.Etcd
|
|
BundlePath string
|
|
ClientConfig *rest.Config
|
|
Client kubernetes.Interface
|
|
Context context.Context
|
|
RBACErrors
|
|
}
|
|
|
|
// etcdDebug is a helper struct to exec into an etcd pod
|
|
type etcdDebug struct {
|
|
context context.Context
|
|
clientConfig *rest.Config
|
|
client kubernetes.Interface
|
|
pod *corev1.Pod // etcd pod to exec into
|
|
ephemeral bool // if true, the pod will be deleted after the collector is done
|
|
commands []string // list of commands to run in the etcd pod
|
|
args []string // list of args to pass to each command
|
|
hostPath string // path to the host's etcd certs
|
|
image string // image to use for the etcd client pod
|
|
}
|
|
|
|
func (c *CollectEtcd) Title() string {
|
|
return getCollectorName(c)
|
|
}
|
|
|
|
func (c *CollectEtcd) IsExcluded() (bool, error) {
|
|
return isExcluded(c.Collector.Exclude)
|
|
}
|
|
|
|
func (c *CollectEtcd) Collect(progressChan chan<- interface{}) (CollectorResult, error) {
|
|
debugInstance := etcdDebug{
|
|
context: c.Context,
|
|
clientConfig: c.ClientConfig,
|
|
client: c.Client,
|
|
commands: []string{
|
|
"etcdctl endpoint health",
|
|
"etcdctl endpoint status",
|
|
"etcdctl member list",
|
|
"etcdctl alarm list",
|
|
"etcdctl version",
|
|
},
|
|
image: c.Collector.Image,
|
|
}
|
|
|
|
distribution, err := debugInstance.getSupportedDistro()
|
|
if err != nil {
|
|
klog.V(2).Infof("etcd collector is not supported on this distribution: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
// etcd on these distros are not running as pod but as a process managed by k0scontroller
|
|
// we have to spin up an etcd pod to exec into and run the commands
|
|
// after the collector is done, the pod will be deleted
|
|
if distribution == "k0s" || distribution == "embedded-cluster" {
|
|
debugInstance.ephemeral = true
|
|
}
|
|
defer debugInstance.cleanup()
|
|
|
|
etcdArgs, hostPath, err := getEtcdArgsByDistribution(distribution)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
debugInstance.args = etcdArgs
|
|
debugInstance.hostPath = hostPath
|
|
|
|
err = debugInstance.getOrCreateEtcdPod()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// wait until the pod is running
|
|
err = debugInstance.waitForPodReady()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// finally exec etcdctl troubleshoot commands
|
|
output := NewResult()
|
|
|
|
for _, command := range debugInstance.commands {
|
|
fileName := generateFilenameFromCommand(command)
|
|
stdout, stderr, err := debugInstance.executeCommand(command)
|
|
if err != nil {
|
|
klog.V(2).Infof("failed to exec command %s: %v", command, err)
|
|
continue
|
|
}
|
|
if len(stdout) > 0 {
|
|
output.SaveResult(c.BundlePath, getFullPath(fileName), bytes.NewBuffer(stdout))
|
|
}
|
|
if len(stderr) > 0 {
|
|
fileName := fmt.Sprintf("%s-stderr", fileName)
|
|
output.SaveResult(c.BundlePath, getFullPath(fileName), bytes.NewBuffer(stderrToJson(stderr)))
|
|
}
|
|
}
|
|
|
|
return output, nil
|
|
}
|
|
|
|
func getEtcdArgsByDistribution(distribution string) ([]string, string, error) {
|
|
type certs struct {
|
|
hostPath string
|
|
ca string
|
|
cert string
|
|
key string
|
|
}
|
|
|
|
lookup := map[string]certs{
|
|
"k0s": {
|
|
hostPath: "/var/lib/k0s/pki/etcd",
|
|
ca: "ca.crt",
|
|
cert: "peer.crt",
|
|
key: "peer.key",
|
|
},
|
|
"embedded-cluster": {
|
|
hostPath: "/var/lib/k0s/pki/etcd",
|
|
ca: "ca.crt",
|
|
cert: "peer.crt",
|
|
key: "peer.key",
|
|
},
|
|
"kurl": {
|
|
hostPath: "/etc/kubernetes/pki/etcd",
|
|
ca: "ca.crt",
|
|
cert: "healthcheck-client.crt",
|
|
key: "healthcheck-client.key",
|
|
},
|
|
}
|
|
|
|
c, ok := lookup[distribution]
|
|
if !ok {
|
|
return nil, "", errors.Errorf("distribution %s not supported", distribution)
|
|
}
|
|
|
|
return []string{
|
|
"--cacert", c.hostPath + "/" + c.ca,
|
|
"--cert", c.hostPath + "/" + c.cert,
|
|
"--key", c.hostPath + "/" + c.key,
|
|
"--write-out", "json",
|
|
"--endpoints", "https://127.0.0.1:2379", // always use localhost
|
|
}, c.hostPath, nil
|
|
}
|
|
|
|
// getSupportedDistro returns the distro that etcd collector can run on
|
|
// either due to the distro has static etcd pod (kurl by kubeadm) or
|
|
// the distro has etcd running as a process (k0s, embedded-cluster)
|
|
func (c *etcdDebug) getSupportedDistro() (string, error) {
|
|
// extract distro logic from analyzer.ParseNodesForProviders
|
|
// pkg/analyze/distribution.go
|
|
// we can't import analyzer because of circular dependency
|
|
// TODO: may refactor this to a common package
|
|
|
|
nodes, err := c.client.CoreV1().Nodes().List(c.context, metav1.ListOptions{})
|
|
if err != nil {
|
|
return "", errors.Wrap(err, "failed to list nodes")
|
|
}
|
|
|
|
for _, node := range nodes.Items {
|
|
for k, v := range node.ObjectMeta.Labels {
|
|
if k == "kurl.sh/cluster" && v == "true" {
|
|
return "kurl", nil
|
|
}
|
|
if k == "node.k0sproject.io/role" {
|
|
return "k0s", nil
|
|
}
|
|
if k == "kots.io/embedded-cluster-role" {
|
|
return "embedded-cluster", nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", errors.New("current k8s distribution does not support etcd collector")
|
|
}
|
|
|
|
func (c *etcdDebug) getOrCreateEtcdPod() error {
|
|
// if ephemeral, create a etcd client pod to exec into
|
|
// the pod will use hostNetwork: true to access the etcd server
|
|
if c.ephemeral {
|
|
err := c.createEtcdPod()
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to create etcd pod")
|
|
}
|
|
return nil
|
|
}
|
|
// if not ephemeral, find the static etcd pod to exec into
|
|
// get the first etcd pod in the cluster with label "component=etcd" in all namespaces
|
|
label := "component=etcd"
|
|
pods, err := c.client.CoreV1().Pods("").List(c.context, metav1.ListOptions{
|
|
LabelSelector: label,
|
|
})
|
|
if err != nil {
|
|
return errors.Wrap(err, fmt.Sprintf("failed to list etcd pods with label %s", label))
|
|
}
|
|
if len(pods.Items) == 0 {
|
|
return errors.New("no static etcd pod found")
|
|
}
|
|
|
|
klog.V(2).Infof("found etcd pod %s in namespace %s", pods.Items[0].Name, pods.Items[0].Namespace)
|
|
c.pod = &pods.Items[0]
|
|
return nil
|
|
}
|
|
|
|
// createEtcdPod creates a etcd client pod to exec into
|
|
func (c *etcdDebug) createEtcdPod() error {
|
|
if c.image == "" {
|
|
c.image = "quay.io/coreos/etcd:latest"
|
|
}
|
|
namespace := "default"
|
|
labels := map[string]string{
|
|
"troubleshoot-role": "etcd-collector",
|
|
}
|
|
spec := &corev1.Pod{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
GenerateName: "etcd-collector-",
|
|
Namespace: namespace,
|
|
Labels: labels,
|
|
},
|
|
Spec: corev1.PodSpec{
|
|
HostNetwork: true,
|
|
Containers: []corev1.Container{
|
|
{
|
|
Name: "etcd-client",
|
|
Image: c.image,
|
|
Command: []string{"sleep"},
|
|
Args: []string{"5m"},
|
|
Env: []corev1.EnvVar{
|
|
{
|
|
Name: "ETCDCTL_API",
|
|
Value: "3",
|
|
}, {
|
|
Name: "ETCDCTL_INSECURE_SKIP_TLS_VERIFY",
|
|
Value: "true",
|
|
},
|
|
},
|
|
VolumeMounts: []corev1.VolumeMount{
|
|
{
|
|
Name: "etcd-certs",
|
|
MountPath: c.hostPath,
|
|
ReadOnly: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Volumes: []corev1.Volume{
|
|
{
|
|
Name: "etcd-certs",
|
|
VolumeSource: corev1.VolumeSource{
|
|
HostPath: &corev1.HostPathVolumeSource{
|
|
Path: c.hostPath,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
klog.V(2).Infof("creating etcd troubleshoot pod in namespace %s", namespace)
|
|
pod, err := c.client.CoreV1().Pods(namespace).Create(c.context, spec, metav1.CreateOptions{})
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to create etcd troubleshoot pod")
|
|
}
|
|
c.pod = pod
|
|
return nil
|
|
}
|
|
|
|
// cleanup deletes the etcd troubleshoot pod if it's ephemeral
|
|
func (c *etcdDebug) cleanup() {
|
|
if !c.ephemeral || c.pod == nil {
|
|
return
|
|
}
|
|
|
|
// delete the pod
|
|
klog.V(2).Infof("deleting etcd troubleshoot pod %s in namespace %s", c.pod.Name, c.pod.Namespace)
|
|
err := c.client.CoreV1().Pods(c.pod.Namespace).Delete(context.Background(), c.pod.Name, metav1.DeleteOptions{
|
|
GracePeriodSeconds: new(int64), // delete immediately
|
|
})
|
|
if err != nil {
|
|
klog.Errorf("failed to delete pod %s: %v", c.pod.Name, err)
|
|
}
|
|
}
|
|
|
|
// executeCommand exec into the pod and run the command
|
|
// it returns the stdout, stderr and error if any of the command
|
|
func (c *etcdDebug) executeCommand(command string) ([]byte, []byte, error) {
|
|
|
|
// split command into a slice of strings
|
|
// e.g. "etcdctl endpoint health" -> ["etcdctl", "endpoint", "health"]
|
|
cdmArgs := strings.Fields(command)
|
|
cdmArgs = append(cdmArgs, c.args...)
|
|
klog.V(2).Infof("executing command: %q in pod %q (namespace %q)", strings.Join(cdmArgs, " "), c.pod.Name, c.pod.Namespace)
|
|
|
|
req := c.client.CoreV1().RESTClient().Post().
|
|
Resource("pods").
|
|
Name(c.pod.Name).
|
|
Namespace(c.pod.Namespace).
|
|
SubResource("exec")
|
|
|
|
req.VersionedParams(&corev1.PodExecOptions{
|
|
Command: cdmArgs,
|
|
Stdin: false,
|
|
Stdout: true,
|
|
Stderr: true,
|
|
TTY: false,
|
|
}, scheme.ParameterCodec)
|
|
|
|
exec, err := k8sutil.NewFallbackExecutor(c.clientConfig, req.URL())
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
err = exec.StreamWithContext(c.context, remotecommand.StreamOptions{
|
|
Stdout: &stdout,
|
|
Stderr: &stderr,
|
|
})
|
|
|
|
return stdout.Bytes(), stderr.Bytes(), err
|
|
}
|
|
|
|
// waitForPodReady waits until the etcd troubleshoot pod is running
|
|
func (c *etcdDebug) waitForPodReady() error {
|
|
timeout := 60 * time.Second
|
|
ticker := time.NewTicker(1 * time.Second)
|
|
|
|
ctx, cancel := context.WithTimeout(c.context, timeout)
|
|
defer cancel()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return errors.New("timeout waiting for etcd troubleshooting pod to be running")
|
|
case <-ticker.C:
|
|
pod, err := c.client.CoreV1().Pods(c.pod.Namespace).Get(c.context, c.pod.Name, metav1.GetOptions{})
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to get etcd troubleshoot pod")
|
|
}
|
|
if pod.Status.Phase == corev1.PodRunning {
|
|
// ok, pod is running
|
|
return nil
|
|
}
|
|
klog.V(2).Infof("waiting for etcd troubleshoot pod %q to be running, current status: %q", c.pod.Name, pod.Status.Phase)
|
|
}
|
|
}
|
|
}
|
|
|
|
// generateFilenameFromCommand generates a filename from the command
|
|
// e.g. "etcdctl endpoint health" -> "endpoint-health"
|
|
func generateFilenameFromCommand(command string) string {
|
|
parts := strings.Fields(command)
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
return strings.Join(parts[1:], "-")
|
|
}
|
|
|
|
// getFullPath returns the full path to the file
|
|
// e.g. "endpoint-health" -> "etcd/endpoint-health.json"
|
|
func getFullPath(fileName string) string {
|
|
return fmt.Sprintf("%s/%s.json", etcdOutputDir, fileName)
|
|
}
|
|
|
|
// stderrToJson converts stderr output to json bytes
|
|
func stderrToJson(stderr []byte) []byte {
|
|
jsonObj := map[string]string{
|
|
"stderr": string(stderr),
|
|
}
|
|
jsonBytes, err := json.Marshal(jsonObj)
|
|
if err != nil {
|
|
klog.Errorf("failed to marshal stderr to json: %v", err)
|
|
return []byte{}
|
|
}
|
|
return jsonBytes
|
|
}
|