From 3e862f0ba2c41491d51ec65b260bb3a78ee3a848 Mon Sep 17 00:00:00 2001 From: Ethan Mosbaugh Date: Wed, 11 Mar 2026 10:20:21 -0700 Subject: [PATCH] add back collect --- .github/workflows/build-test-deploy.yaml | 15 +- cmd/collect/cli/chroot_darwin.go | 21 +++ cmd/collect/cli/chroot_linux.go | 21 +++ cmd/collect/cli/chroot_windows.go | 9 ++ cmd/collect/cli/root.go | 90 +++++++++++ cmd/collect/cli/run.go | 189 +++++++++++++++++++++++ cmd/collect/cli/stdout_results.go | 103 ++++++++++++ cmd/collect/main.go | 10 ++ deploy/.goreleaser.yaml | 25 +++ deploy/Dockerfile.troubleshoot | 1 + pkg/supportbundle/collect.go | 4 +- 11 files changed, 485 insertions(+), 3 deletions(-) create mode 100644 cmd/collect/cli/chroot_darwin.go create mode 100644 cmd/collect/cli/chroot_linux.go create mode 100644 cmd/collect/cli/chroot_windows.go create mode 100644 cmd/collect/cli/root.go create mode 100644 cmd/collect/cli/run.go create mode 100644 cmd/collect/cli/stdout_results.go create mode 100644 cmd/collect/main.go diff --git a/.github/workflows/build-test-deploy.yaml b/.github/workflows/build-test-deploy.yaml index 269e2690..40e36343 100644 --- a/.github/workflows/build-test-deploy.yaml +++ b/.github/workflows/build-test-deploy.yaml @@ -94,6 +94,19 @@ jobs: name: support-bundle path: bin/support-bundle + compile-collect: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + - run: make generate collect + - uses: actions/upload-artifact@v4 + with: + name: collect + path: bin/collect + validate-supportbundle-e2e: runs-on: ubuntu-latest needs: compile-supportbundle @@ -114,7 +127,7 @@ jobs: # Additional e2e tests for support bundle that run in Go, these create a Kind cluster validate-supportbundle-e2e-go: runs-on: ubuntu-latest - needs: compile-supportbundle + needs: [compile-supportbundle, compile-collect] steps: - uses: actions/checkout@v6 - name: Download support bundle binary diff --git a/cmd/collect/cli/chroot_darwin.go b/cmd/collect/cli/chroot_darwin.go new file mode 100644 index 00000000..c24bf112 --- /dev/null +++ b/cmd/collect/cli/chroot_darwin.go @@ -0,0 +1,21 @@ +package cli + +import ( + "errors" + "syscall" + + "github.com/replicatedhq/troubleshoot/internal/util" +) + +func checkAndSetChroot(newroot string) error { + if newroot == "" { + return nil + } + if !util.IsRunningAsRoot() { + return errors.New("Can only chroot when run as root") + } + if err := syscall.Chroot(newroot); err != nil { + return err + } + return nil +} diff --git a/cmd/collect/cli/chroot_linux.go b/cmd/collect/cli/chroot_linux.go new file mode 100644 index 00000000..c24bf112 --- /dev/null +++ b/cmd/collect/cli/chroot_linux.go @@ -0,0 +1,21 @@ +package cli + +import ( + "errors" + "syscall" + + "github.com/replicatedhq/troubleshoot/internal/util" +) + +func checkAndSetChroot(newroot string) error { + if newroot == "" { + return nil + } + if !util.IsRunningAsRoot() { + return errors.New("Can only chroot when run as root") + } + if err := syscall.Chroot(newroot); err != nil { + return err + } + return nil +} diff --git a/cmd/collect/cli/chroot_windows.go b/cmd/collect/cli/chroot_windows.go new file mode 100644 index 00000000..84b349a2 --- /dev/null +++ b/cmd/collect/cli/chroot_windows.go @@ -0,0 +1,9 @@ +package cli + +import ( + "errors" +) + +func checkAndSetChroot(newroot string) error { + return errors.New("chroot is only implimented in linux/darwin") +} diff --git a/cmd/collect/cli/root.go b/cmd/collect/cli/root.go new file mode 100644 index 00000000..86fdc5c6 --- /dev/null +++ b/cmd/collect/cli/root.go @@ -0,0 +1,90 @@ +package cli + +import ( + "os" + "strings" + + "github.com/replicatedhq/troubleshoot/cmd/internal/util" + "github.com/replicatedhq/troubleshoot/pkg/k8sutil" + "github.com/replicatedhq/troubleshoot/pkg/logger" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "k8s.io/klog/v2" +) + +func RootCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "collect [url]", + Args: cobra.MinimumNArgs(1), + Short: "Run a collector", + Long: `Run a collector and output the results.`, + SilenceUsage: true, + PreRun: func(cmd *cobra.Command, args []string) { + v := viper.GetViper() + v.BindPFlags(cmd.Flags()) + + logger.SetupLogger(v) + + if err := util.StartProfiling(); err != nil { + klog.Errorf("Failed to start profiling: %v", err) + } + }, + RunE: func(cmd *cobra.Command, args []string) error { + v := viper.GetViper() + + if err := checkAndSetChroot(v.GetString("chroot")); err != nil { + return err + } + + return runCollect(v, args[0]) + }, + PostRun: func(cmd *cobra.Command, args []string) { + if err := util.StopProfiling(); err != nil { + klog.Errorf("Failed to stop profiling: %v", err) + } + }, + } + + cobra.OnInitialize(initConfig) + + cmd.AddCommand(util.VersionCmd()) + + cmd.Flags().StringSlice("redactors", []string{}, "names of the additional redactors to use") + cmd.Flags().Bool("redact", true, "enable/disable default redactions") + cmd.Flags().String("format", "json", "output format, one of json or raw.") + cmd.Flags().String("collector-image", "", "the full name of the collector image to use") + cmd.Flags().String("collector-pull-policy", "", "the pull policy of the collector image") + cmd.Flags().String("selector", "", "selector (label query) to filter remote collection nodes on.") + cmd.Flags().Bool("collect-without-permissions", false, "always generate a support bundle, even if it some require additional permissions") + cmd.Flags().Bool("debug", false, "enable debug logging") + cmd.Flags().String("chroot", "", "Chroot to path") + + // hidden in favor of the `insecure-skip-tls-verify` flag + cmd.Flags().Bool("allow-insecure-connections", false, "when set, do not verify TLS certs when retrieving spec and reporting results") + cmd.Flags().MarkHidden("allow-insecure-connections") + + viper.BindPFlags(cmd.Flags()) + + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + + k8sutil.AddFlags(cmd.Flags()) + + // Initialize klog flags + logger.InitKlogFlags(cmd) + + // CPU and memory profiling flags + util.AddProfilingFlags(cmd) + + return cmd +} + +func InitAndExecute() { + if err := RootCmd().Execute(); err != nil { + os.Exit(1) + } +} + +func initConfig() { + viper.SetEnvPrefix("TROUBLESHOOT") + viper.AutomaticEnv() +} diff --git a/cmd/collect/cli/run.go b/cmd/collect/cli/run.go new file mode 100644 index 00000000..42771b67 --- /dev/null +++ b/cmd/collect/cli/run.go @@ -0,0 +1,189 @@ +package cli + +import ( + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "os/signal" + "strings" + "time" + + "github.com/pkg/errors" + "github.com/replicatedhq/troubleshoot/internal/util" + troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" + "github.com/replicatedhq/troubleshoot/pkg/client/troubleshootclientset/scheme" + "github.com/replicatedhq/troubleshoot/pkg/collect" + "github.com/replicatedhq/troubleshoot/pkg/docrewrite" + "github.com/replicatedhq/troubleshoot/pkg/k8sutil" + "github.com/replicatedhq/troubleshoot/pkg/specs" + "github.com/replicatedhq/troubleshoot/pkg/supportbundle" + "github.com/spf13/viper" + "k8s.io/apimachinery/pkg/labels" +) + +const ( + defaultTimeout = 30 * time.Second +) + +func runCollect(v *viper.Viper, arg string) error { + go func() { + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, os.Interrupt) + <-signalChan + os.Exit(0) + }() + + var collectorContent []byte + var err error + if strings.HasPrefix(arg, "secret/") { + // format secret/namespace-name/secret-name + pathParts := strings.Split(arg, "/") + if len(pathParts) != 3 { + return errors.Errorf("path %s must have 3 components", arg) + } + + spec, err := specs.LoadFromSecret(pathParts[1], pathParts[2], "collect-spec") + if err != nil { + return errors.Wrap(err, "failed to get spec from secret") + } + + collectorContent = spec + } else if arg == "-" { + b, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + + collectorContent = b + } else if _, err = os.Stat(arg); err == nil { + b, err := os.ReadFile(arg) + if err != nil { + return err + } + + collectorContent = b + } else { + if !util.IsURL(arg) { + return fmt.Errorf("%s is not a URL and was not found", arg) + } + + req, err := http.NewRequest("GET", arg, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "Replicated_Collect/v1beta2") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + + collectorContent = body + } + + collectorContent, err = docrewrite.ConvertToV1Beta2(collectorContent) + if err != nil { + return errors.Wrap(err, "failed to convert to v1beta2") + } + + multidocs := strings.Split(string(collectorContent), "\n---\n") + + decode := scheme.Codecs.UniversalDeserializer().Decode + + redactors, err := supportbundle.GetRedactorsFromURIs(v.GetStringSlice("redactors")) + if err != nil { + return errors.Wrap(err, "failed to get redactors") + } + + additionalRedactors := &troubleshootv1beta2.Redactor{ + Spec: troubleshootv1beta2.RedactorSpec{ + Redactors: redactors, + }, + } + + for i, additionalDoc := range multidocs { + if i == 0 { + continue + } + additionalDoc, err := docrewrite.ConvertToV1Beta2([]byte(additionalDoc)) + if err != nil { + return errors.Wrap(err, "failed to convert to v1beta2") + } + obj, _, err := decode(additionalDoc, nil, nil) + if err != nil { + return errors.Wrapf(err, "failed to parse additional doc %d", i) + } + multidocRedactors, ok := obj.(*troubleshootv1beta2.Redactor) + if !ok { + continue + } + additionalRedactors.Spec.Redactors = append(additionalRedactors.Spec.Redactors, multidocRedactors.Spec.Redactors...) + } + + // make sure we don't block any senders + progressCh := make(chan interface{}) + defer close(progressCh) + go func() { + for range progressCh { + } + }() + + restConfig, err := k8sutil.GetRESTConfig() + if err != nil { + return errors.Wrap(err, "failed to convert kube flags to rest config") + } + + labelSelector, err := labels.Parse(v.GetString("selector")) + if err != nil { + return errors.Wrap(err, "unable to parse selector") + } + + namespace := v.GetString("namespace") + if namespace == "" { + namespace = "default" + } + + timeout := v.GetDuration("request-timeout") + if timeout == 0 { + timeout = defaultTimeout + } + + createOpts := collect.CollectorRunOpts{ + CollectWithoutPermissions: v.GetBool("collect-without-permissions"), + KubernetesRestConfig: restConfig, + Image: v.GetString("collector-image"), + PullPolicy: v.GetString("collector-pullpolicy"), + LabelSelector: labelSelector.String(), + Namespace: namespace, + Timeout: timeout, + ProgressChan: progressCh, + } + + // we only support HostCollector or RemoteCollector kinds. + hostCollector, err := collect.ParseHostCollectorFromDoc([]byte(multidocs[0])) + if err == nil { + results, err := collect.CollectHost(hostCollector, additionalRedactors, createOpts) + if err != nil { + return errors.Wrap(err, "failed to collect from host") + } + return showHostStdoutResults(v.GetString("format"), hostCollector.Name, results) + } + + remoteCollector, err := collect.ParseRemoteCollectorFromDoc([]byte(multidocs[0])) + if err == nil { + results, err := collect.CollectRemote(remoteCollector, additionalRedactors, createOpts) + if err != nil { + return errors.Wrap(err, "failed to collect from remote host(s)") + } + return showRemoteStdoutResults(v.GetString("format"), remoteCollector.Name, results) + } + + return errors.New("failed to parse hostCollector or remoteCollector") +} diff --git a/cmd/collect/cli/stdout_results.go b/cmd/collect/cli/stdout_results.go new file mode 100644 index 00000000..50916683 --- /dev/null +++ b/cmd/collect/cli/stdout_results.go @@ -0,0 +1,103 @@ +package cli + +import ( + "encoding/json" + "fmt" + + "github.com/pkg/errors" + "github.com/replicatedhq/troubleshoot/pkg/collect" +) + +const ( + // FormatJSON is intended for CLI output. + FormatJSON = "json" + + // FormatRaw is intended for consumption by a remote collector. Output is a + // string of quoted JSON. + FormatRaw = "raw" +) + +func showHostStdoutResults(format string, collectName string, results *collect.HostCollectResult) error { + switch format { + case FormatJSON: + return showHostStdoutResultsJSON(collectName, results.AllCollectedData) + case FormatRaw: + return showHostStdoutResultsRaw(collectName, results.AllCollectedData) + default: + return errors.Errorf("unknown output format: %q", format) + } +} + +func showRemoteStdoutResults(format string, collectName string, results *collect.RemoteCollectResult) error { + switch format { + case FormatJSON: + return showRemoteStdoutResultsJSON(collectName, results.AllCollectedData) + case FormatRaw: + return errors.Errorf("raw format not supported for remote collectors") + default: + return errors.Errorf("unknown output format: %q", format) + } +} + +func showHostStdoutResultsJSON(collectName string, results map[string][]byte) error { + output := make(map[string]interface{}) + for file, collectorResult := range results { + var collectedItems map[string]interface{} + if err := json.Unmarshal([]byte(collectorResult), &collectedItems); err != nil { + return errors.Wrap(err, "failed to marshal collector results") + } + output[file] = collectedItems + } + + formatted, err := json.MarshalIndent(output, "", " ") + if err != nil { + return errors.Wrap(err, "failed to convert output to json") + } + + fmt.Print(string(formatted)) + return nil +} + +// showHostStdoutResultsRaw outputs the collector output as a string of quoted json. +func showHostStdoutResultsRaw(collectName string, results map[string][]byte) error { + strData := map[string]string{} + for k, v := range results { + strData[k] = string(v) + } + formatted, err := json.MarshalIndent(strData, "", " ") + if err != nil { + return errors.Wrap(err, "failed to convert output to json") + } + fmt.Print(string(formatted)) + return nil +} + +func showRemoteStdoutResultsJSON(collectName string, results map[string][]byte) error { + type CollectorResult map[string]interface{} + type NodeResult map[string]CollectorResult + + var output = make(map[string]NodeResult) + + for node, result := range results { + var nodeResult map[string]string + if err := json.Unmarshal(result, &nodeResult); err != nil { + return errors.Wrap(err, "failed to marshal node results") + } + nr := make(NodeResult) + for file, collectorResult := range nodeResult { + var collectedItems map[string]interface{} + if err := json.Unmarshal([]byte(collectorResult), &collectedItems); err != nil { + return errors.Wrap(err, "failed to marshal collector results") + } + nr[file] = collectedItems + } + output[node] = nr + } + + formatted, err := json.MarshalIndent(output, "", " ") + if err != nil { + return errors.Wrap(err, "failed to convert output to json") + } + fmt.Print(string(formatted)) + return nil +} diff --git a/cmd/collect/main.go b/cmd/collect/main.go new file mode 100644 index 00000000..7238c046 --- /dev/null +++ b/cmd/collect/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/replicatedhq/troubleshoot/cmd/collect/cli" + _ "k8s.io/client-go/plugin/pkg/client/auth" +) + +func main() { + cli.InitAndExecute() +} diff --git a/deploy/.goreleaser.yaml b/deploy/.goreleaser.yaml index af35d65f..8f0e8659 100644 --- a/deploy/.goreleaser.yaml +++ b/deploy/.goreleaser.yaml @@ -51,6 +51,29 @@ builds: - -installsuffix=netgo binary: support-bundle + - id: collect + main: ./cmd/collect/main.go + env: [CGO_ENABLED=0] + goos: [linux, darwin, windows] + goarch: [amd64, arm, arm64] + ignore: + - goos: windows + goarch: arm + ldflags: + - -s -w + - -X github.com/replicatedhq/troubleshoot/pkg/version.version={{ .Version }} + - -X github.com/replicatedhq/troubleshoot/pkg/version.gitSHA={{ .Commit }} + - -X github.com/replicatedhq/troubleshoot/pkg/version.buildTime={{ .Date }} + - -extldflags "-static" + flags: + - -tags=netgo + - -tags=containers_image_ostree_stub + - -tags=exclude_graphdriver_devicemapper + - -tags=exclude_graphdriver_btrfs + - -tags=containers_image_openpgp + - -installsuffix=netgo + binary: collect + archives: - id: preflight ids: [preflight] @@ -135,6 +158,7 @@ dockers: ids: - support-bundle - preflight + - collect - dockerfile: ./deploy/Dockerfile.troubleshoot image_templates: - "replicated/preflight:latest" @@ -144,6 +168,7 @@ dockers: ids: - support-bundle - preflight + - collect universal_binaries: - id: preflight-universal diff --git a/deploy/Dockerfile.troubleshoot b/deploy/Dockerfile.troubleshoot index 87182c41..7b763824 100644 --- a/deploy/Dockerfile.troubleshoot +++ b/deploy/Dockerfile.troubleshoot @@ -7,6 +7,7 @@ RUN apt-get -qq update \ COPY support-bundle /troubleshoot/support-bundle COPY preflight /troubleshoot/preflight +COPY collect /troubleshoot/collect ENV PATH="/troubleshoot:${PATH}" diff --git a/pkg/supportbundle/collect.go b/pkg/supportbundle/collect.go index 718562e6..f4d572d1 100644 --- a/pkg/supportbundle/collect.go +++ b/pkg/supportbundle/collect.go @@ -448,12 +448,12 @@ func runRemoteHostCollectors(ctx context.Context, hostCollectors []*troubleshoot return err } - stdout, _, err := getExecOutputs(ctx, opts.KubernetesRestConfig, clientset, pod, specJSON) + stdout, stderr, err := getExecOutputs(ctx, opts.KubernetesRestConfig, clientset, pod, specJSON) if err != nil { // span.SetStatus(codes.Error, err.Error()) msg := fmt.Sprintf("[%s] Error: %v", collector.Title(), err) opts.CollectorProgressCallback(opts.ProgressChan, msg) - return errors.Wrap(err, "failed to run remote host collector") + return errors.Wrapf(err, "failed to run remote host collector: %s", string(stderr)) } result := map[string]string{}