diff --git a/cmd/troubleshoot/cli/run.go b/cmd/troubleshoot/cli/run.go index f141ed87..89cfef52 100644 --- a/cmd/troubleshoot/cli/run.go +++ b/cmd/troubleshoot/cli/run.go @@ -110,7 +110,7 @@ func runTroubleshoot(v *viper.Viper, args []string) error { } if interactive { - if len(mainBundle.Spec.HostCollectors) > 0 && !util.IsRunningAsRoot() { + if len(mainBundle.Spec.HostCollectors) > 0 && !util.IsRunningAsRoot() && !mainBundle.Spec.RunHostCollectorsInPod { fmt.Print(cursor.Show()) if util.PromptYesNo(util.HOST_COLLECTORS_RUN_AS_ROOT_PROMPT) { fmt.Println("Exiting...") @@ -184,7 +184,7 @@ func runTroubleshoot(v *viper.Viper, args []string) error { OutputPath: v.GetString("output"), Redact: v.GetBool("redact"), FromCLI: true, - RunHostCollectorsInPod: mainBundle.Metadata.RunHostCollectorsInPod, + RunHostCollectorsInPod: mainBundle.Spec.RunHostCollectorsInPod, } nonInteractiveOutput := analysisOutput{} @@ -199,7 +199,7 @@ func runTroubleshoot(v *viper.Viper, args []string) error { if len(response.AnalyzerResults) > 0 { if interactive { - if err := showInteractiveResults(mainBundle.Metadata.Name, response.AnalyzerResults, response.ArchivePath); err != nil { + if err := showInteractiveResults(mainBundle.Name, response.AnalyzerResults, response.ArchivePath); err != nil { interactive = false } } else { @@ -208,7 +208,7 @@ func runTroubleshoot(v *viper.Viper, args []string) error { } if !response.FileUploaded { - if appName := mainBundle.Metadata.Labels["applicationName"]; appName != "" { + if appName := mainBundle.Labels["applicationName"]; appName != "" { f := `A support bundle for %s has been created in this directory named %s. Please upload it on the Troubleshoot page of the %s Admin Console to begin analysis.` @@ -337,11 +337,8 @@ func loadSpecs(ctx context.Context, args []string, client kubernetes.Interface) APIVersion: "troubleshoot.sh/v1beta2", Kind: "SupportBundle", }, - Metadata: troubleshootv1beta2.SupportBundleMetadata{ - ObjectMeta: metav1.ObjectMeta{ - Name: "merged-support-bundle-spec", - }, - RunHostCollectorsInPod: false, + ObjectMeta: metav1.ObjectMeta{ + Name: "merged-support-bundle-spec", }, } @@ -351,11 +348,11 @@ func loadSpecs(ctx context.Context, args []string, client kubernetes.Interface) sb := sb mainBundle = supportbundle.ConcatSpec(mainBundle, &sb) //check if sb has metadata and if it has RunHostCollectorsInPod set to true - if !reflect.DeepEqual(sb.Metadata.ObjectMeta, metav1.ObjectMeta{}) && sb.Metadata.RunHostCollectorsInPod { - enableRunHostCollectorsInPod = sb.Metadata.RunHostCollectorsInPod + if !reflect.DeepEqual(sb.ObjectMeta, metav1.ObjectMeta{}) && sb.Spec.RunHostCollectorsInPod { + enableRunHostCollectorsInPod = sb.Spec.RunHostCollectorsInPod } } - mainBundle.Metadata.RunHostCollectorsInPod = enableRunHostCollectorsInPod + mainBundle.Spec.RunHostCollectorsInPod = enableRunHostCollectorsInPod for _, c := range kinds.CollectorsV1Beta2 { mainBundle.Spec.Collectors = util.Append(mainBundle.Spec.Collectors, c.Spec.Collectors) diff --git a/config/crds/troubleshoot.sh_supportbundles.yaml b/config/crds/troubleshoot.sh_supportbundles.yaml index 9c77af9d..220bd1c3 100644 --- a/config/crds/troubleshoot.sh_supportbundles.yaml +++ b/config/crds/troubleshoot.sh_supportbundles.yaml @@ -2114,6 +2114,8 @@ spec: type: string exclude: type: BoolString + image: + type: string namespace: type: string podLaunchOptions: @@ -20313,6 +20315,8 @@ spec: type: object type: object type: array + runHostCollectorsInPod: + type: boolean uri: description: URI optionally defines a location which is the source of this spec to allow updating of the spec at runtime diff --git a/pkg/apis/troubleshoot/v1beta2/remote_collector_shared.go b/pkg/apis/troubleshoot/v1beta2/remote_collector_shared.go index 25c99acc..e6a0e070 100644 --- a/pkg/apis/troubleshoot/v1beta2/remote_collector_shared.go +++ b/pkg/apis/troubleshoot/v1beta2/remote_collector_shared.go @@ -16,6 +16,7 @@ type RemoteCollectorMeta struct { type RemoteCPU struct { RemoteCollectorMeta `json:",inline" yaml:",inline"` } + type RemoteHostOS struct { RemoteCollectorMeta `json:",inline" yaml:",inline"` } diff --git a/pkg/apis/troubleshoot/v1beta2/supportbundle_types.go b/pkg/apis/troubleshoot/v1beta2/supportbundle_types.go index 4c1a6df3..44e01701 100644 --- a/pkg/apis/troubleshoot/v1beta2/supportbundle_types.go +++ b/pkg/apis/troubleshoot/v1beta2/supportbundle_types.go @@ -20,11 +20,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -type SupportBundleMetadata struct { - metav1.ObjectMeta `json:",inline" yaml:",inline"` - RunHostCollectorsInPod bool `json:"runHostCollectorsInPod,omitempty" yaml:"runHostCollectorsInPod,omitempty"` -} - // SupportBundleSpec defines the desired state of SupportBundle type SupportBundleSpec struct { AfterCollection []*AfterCollection `json:"afterCollection,omitempty" yaml:"afterCollection,omitempty"` @@ -33,7 +28,8 @@ type SupportBundleSpec struct { Analyzers []*Analyze `json:"analyzers,omitempty" yaml:"analyzers,omitempty"` HostAnalyzers []*HostAnalyze `json:"hostAnalyzers,omitempty" yaml:"hostAnalyzers,omitempty"` // URI optionally defines a location which is the source of this spec to allow updating of the spec at runtime - Uri string `json:"uri,omitempty" yaml:"uri,omitempty"` + Uri string `json:"uri,omitempty" yaml:"uri,omitempty"` + RunHostCollectorsInPod bool `json:"runHostCollectorsInPod,omitempty" yaml:"runHostCollectorsInPod,omitempty"` } // SupportBundleStatus defines the observed state of SupportBundle @@ -48,8 +44,8 @@ type SupportBundleStatus struct { // SupportBundle is the Schema for the SupportBundles API // +k8s:openapi-gen=true type SupportBundle struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - Metadata SupportBundleMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` + metav1.TypeMeta `json:",inline" yaml:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty" yaml:"metadata,omitempty"` Spec SupportBundleSpec `json:"spec,omitempty" yaml:"spec,omitempty"` Status SupportBundleStatus `json:"status,omitempty"` diff --git a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go index 6172d11f..5b40aca8 100644 --- a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go +++ b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go @@ -4641,7 +4641,7 @@ func (in *SubnetAvailableAnalyze) DeepCopy() *SubnetAvailableAnalyze { func (in *SupportBundle) DeepCopyInto(out *SupportBundle) { *out = *in out.TypeMeta = in.TypeMeta - in.Metadata.DeepCopyInto(&out.Metadata) + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) out.Status = in.Status } @@ -4696,22 +4696,6 @@ func (in *SupportBundleList) DeepCopyObject() runtime.Object { return nil } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *SupportBundleMetadata) DeepCopyInto(out *SupportBundleMetadata) { - *out = *in - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SupportBundleMetadata. -func (in *SupportBundleMetadata) DeepCopy() *SupportBundleMetadata { - if in == nil { - return nil - } - out := new(SupportBundleMetadata) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SupportBundleSpec) DeepCopyInto(out *SupportBundleSpec) { *out = *in diff --git a/pkg/client/troubleshootclientset/typed/troubleshoot/v1beta2/fake/fake_supportbundle.go b/pkg/client/troubleshootclientset/typed/troubleshoot/v1beta2/fake/fake_supportbundle.go index 45d40c88..b28fc402 100644 --- a/pkg/client/troubleshootclientset/typed/troubleshoot/v1beta2/fake/fake_supportbundle.go +++ b/pkg/client/troubleshootclientset/typed/troubleshoot/v1beta2/fake/fake_supportbundle.go @@ -64,7 +64,7 @@ func (c *FakeSupportBundles) List(ctx context.Context, opts v1.ListOptions) (res } list := &v1beta2.SupportBundleList{ListMeta: obj.(*v1beta2.SupportBundleList).ListMeta} for _, item := range obj.(*v1beta2.SupportBundleList).Items { - if label.Matches(labels.Set(item.Metadata.Labels)) { + if label.Matches(labels.Set(item.Labels)) { list.Items = append(list.Items, item) } } diff --git a/pkg/client/troubleshootclientset/typed/troubleshoot/v1beta2/supportbundle.go b/pkg/client/troubleshootclientset/typed/troubleshoot/v1beta2/supportbundle.go index 521f4df9..49399e76 100644 --- a/pkg/client/troubleshootclientset/typed/troubleshoot/v1beta2/supportbundle.go +++ b/pkg/client/troubleshootclientset/typed/troubleshoot/v1beta2/supportbundle.go @@ -127,7 +127,7 @@ func (c *supportBundles) Update(ctx context.Context, supportBundle *v1beta2.Supp err = c.client.Put(). Namespace(c.ns). Resource("supportbundles"). - Name(supportBundle.Metadata.Name). + Name(supportBundle.Name). VersionedParams(&opts, scheme.ParameterCodec). Body(supportBundle). Do(ctx). @@ -142,7 +142,7 @@ func (c *supportBundles) UpdateStatus(ctx context.Context, supportBundle *v1beta err = c.client.Put(). Namespace(c.ns). Resource("supportbundles"). - Name(supportBundle.Metadata.Name). + Name(supportBundle.Name). SubResource("status"). VersionedParams(&opts, scheme.ParameterCodec). Body(supportBundle). diff --git a/pkg/collect/cluster_resources_test.go b/pkg/collect/cluster_resources_test.go index f8bf6c0a..d261d02e 100644 --- a/pkg/collect/cluster_resources_test.go +++ b/pkg/collect/cluster_resources_test.go @@ -481,11 +481,9 @@ func TestCollectClusterResources_CustomResource(t *testing.T) { // Create a CR sbObject := troubleshootv1beta2.SupportBundle{ - Metadata: troubleshootv1beta2.SupportBundleMetadata{ - ObjectMeta: metav1.ObjectMeta{ - Name: "supportbundle", - Namespace: "default", - }, + ObjectMeta: metav1.ObjectMeta{ + Name: "supportbundle", + Namespace: "default", }, TypeMeta: metav1.TypeMeta{ Kind: "SupportBundle", diff --git a/pkg/collect/host_collector.go b/pkg/collect/host_collector.go index 04e96de0..767313a5 100644 --- a/pkg/collect/host_collector.go +++ b/pkg/collect/host_collector.go @@ -1,14 +1,43 @@ package collect import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/pkg/errors" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" + "github.com/replicatedhq/troubleshoot/pkg/constants" + "golang.org/x/sync/errgroup" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/storage/names" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" ) type HostCollector interface { Title() string IsExcluded() (bool, error) Collect(progressChan chan<- interface{}) (map[string][]byte, error) - RemoteCollect(progressChan chan<- interface{}) (map[string][]byte, error) // RemoteCollect is used to priviledge pods to collect data from different nodes +} + +type RemoteCollectParams struct { + ProgressChan chan<- interface{} + HostCollector *troubleshootv1beta2.HostCollect + BundlePath string + ClientConfig *rest.Config // specify actual type + Image string + PullPolicy string // specify actual type if needed + Timeout time.Duration // specify duration type if needed + LabelSelector string + NamePrefix string + Namespace string + Title string } func GetHostCollector(collector *troubleshootv1beta2.HostCollect, bundlePath string) (HostCollector, bool) { @@ -81,3 +110,131 @@ func hostCollectorTitleOrDefault(meta troubleshootv1beta2.HostCollectorMeta, def } return defaultTitle } + +func RemoteHostCollect(ctx context.Context, params RemoteCollectParams) (map[string][]byte, error) { + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + return nil, errors.Wrap(err, "failed to add runtime scheme") + } + + client, err := kubernetes.NewForConfig(params.ClientConfig) + if err != nil { + return nil, err + } + + runner := &podRunner{ + client: client, + scheme: scheme, + image: params.Image, + pullPolicy: params.PullPolicy, + waitInterval: remoteCollectorDefaultInterval, + } + + // Get all the nodes where we should run. + nodes, err := listNodesNamesInSelector(ctx, client, params.LabelSelector) + if err != nil { + return nil, errors.Wrap(err, "failed to get the list of nodes matching a nodeSelector") + } + + if params.NamePrefix == "" { + params.NamePrefix = remoteCollectorNamePrefix + } + + result, err := runRemote(ctx, runner, nodes, params.HostCollector, names.SimpleNameGenerator, params.NamePrefix, params.Namespace) + if err != nil { + return nil, errors.Wrap(err, "failed to run collector remotely") + } + + allCollectedData := mapCollectorResultToOutput(result, params) + output := NewResult() + + // save the first result we find in the node and save it + for node, result := range allCollectedData { + var nodeResult map[string]string + if err := json.Unmarshal(result, &nodeResult); err != nil { + return nil, errors.Wrap(err, "failed to marshal node results") + } + + for file, collectorResult := range nodeResult { + directory := filepath.Dir(file) + fileName := filepath.Base(file) + // expected file name for remote collectors will be the normal path separated by / and the node name + output.SaveResult(params.BundlePath, fmt.Sprintf("%s/%s/%s", directory, node, fileName), bytes.NewBufferString(collectorResult)) + } + } + + // check if NODE_LIST_FILE exists + _, err = os.Stat(constants.NODE_LIST_FILE) + // if it not exists, save the nodes list + if err != nil { + nodesBytes, err := json.MarshalIndent(HostOSInfoNodes{Nodes: nodes}, "", " ") + if err != nil { + return nil, errors.Wrap(err, "failed to marshal host os info nodes") + } + output.SaveResult(params.BundlePath, constants.NODE_LIST_FILE, bytes.NewBuffer(nodesBytes)) + } + return output, nil +} + +func runRemote(ctx context.Context, runner runner, nodes []string, collector *troubleshootv1beta2.HostCollect, nameGenerator names.NameGenerator, namePrefix string, namespace string) (map[string][]byte, error) { + g, ctx := errgroup.WithContext(ctx) + results := make(chan map[string][]byte, len(nodes)) + + for _, node := range nodes { + node := node + g.Go(func() error { + // May need to evaluate error and log warning. Otherwise any error + // here will cancel the context of other goroutines and no results + // will be returned. + return runner.run(ctx, collector, namespace, nameGenerator.GenerateName(namePrefix+"-"), node, results) + }) + } + + // Wait for all collectors to complete or return the first error. + if err := g.Wait(); err != nil { + return nil, errors.Wrap(err, "failed remote collection") + } + close(results) + + output := make(map[string][]byte) + for result := range results { + r := result + for k, v := range r { + output[k] = v + } + } + + return output, nil +} + +func mapCollectorResultToOutput(result map[string][]byte, params RemoteCollectParams) map[string][]byte { + allCollectedData := make(map[string][]byte) + + for k, v := range result { + if curBytes, ok := allCollectedData[k]; ok { + var curResults map[string]string + if err := json.Unmarshal(curBytes, &curResults); err != nil { + params.ProgressChan <- errors.Errorf("failed to read existing results for collector %s: %v\n", params.Title, err) + continue + } + var newResults map[string]string + if err := json.Unmarshal(v, &newResults); err != nil { + params.ProgressChan <- errors.Errorf("failed to read new results for collector %s: %v\n", params.Title, err) + continue + } + for file, data := range newResults { + curResults[file] = data + } + combinedResults, err := json.Marshal(curResults) + if err != nil { + params.ProgressChan <- errors.Errorf("failed to combine results for collector %s: %v\n", params.Title, err) + continue + } + allCollectedData[k] = combinedResults + } else { + allCollectedData[k] = v + } + + } + return allCollectedData +} diff --git a/pkg/collect/host_collector_test.go b/pkg/collect/host_collector_test.go new file mode 100644 index 00000000..3ce88b50 --- /dev/null +++ b/pkg/collect/host_collector_test.go @@ -0,0 +1,39 @@ +package collect + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +type Params struct { + Title string + ProgressChan chan error +} + +// Mock data for testing +var testParams = RemoteCollectParams{ + Title: "Test", + ProgressChan: make(chan interface{}), +} + +func Test_mapCollectorResultToOutput(t *testing.T) { + result := map[string][]byte{ + "key1": []byte(`{"file1": "data1", "file2": "data2"}`), + "key2": []byte(`{"file3": "data3"}`), + } + + // Expected output after processing + expectedCollectedData := map[string][]byte{ + "key1": []byte(`{"file1": "data1", "file2": "data2"}`), + "key2": []byte(`{"file3": "data3"}`), + } + + // Run the function logic + allCollectedData := mapCollectorResultToOutput(result, testParams) + + // Validate the collected data + for key, expected := range expectedCollectedData { + assert.Equal(t, string(expected), string(allCollectedData[key]), "The collected data for key %s is incorrect", key) + } +} diff --git a/pkg/collect/host_os_info.go b/pkg/collect/host_os_info.go index a3c8fa6e..79fc5ab9 100644 --- a/pkg/collect/host_os_info.go +++ b/pkg/collect/host_os_info.go @@ -3,11 +3,9 @@ package collect import ( "bytes" "encoding/json" - "fmt" "github.com/pkg/errors" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" - "github.com/replicatedhq/troubleshoot/pkg/k8sutil" osutils "github.com/shirou/gopsutil/v3/host" ) @@ -60,61 +58,3 @@ func (c *CollectHostOS) Collect(progressChan chan<- interface{}) (map[string][]b return output, nil } - -func (c *CollectHostOS) RemoteCollect(progressChan chan<- interface{}) (map[string][]byte, error) { - restConfig, err := k8sutil.GetRESTConfig() - if err != nil { - return nil, errors.Wrap(err, "failed to convert kube flags to rest config") - } - - createOpts := CollectorRunOpts{ - KubernetesRestConfig: restConfig, - Image: "replicated/troubleshoot:latest", - Namespace: "default", - Timeout: defaultTimeout, - NamePrefix: "hostos-remote", - ProgressChan: progressChan, - } - - remoteCollector := &troubleshootv1beta2.RemoteCollector{ - Spec: troubleshootv1beta2.RemoteCollectorSpec{ - Collectors: []*troubleshootv1beta2.RemoteCollect{ - { - HostOS: &troubleshootv1beta2.RemoteHostOS{}, - }, - }, - }, - } - // empty redactor for now - additionalRedactors := &troubleshootv1beta2.Redactor{} - // re-use the collect.CollectRemote function to run the remote collector - results, err := CollectRemote(remoteCollector, additionalRedactors, createOpts) - if err != nil { - return nil, errors.Wrap(err, "failed to run remote collector") - } - - output := NewResult() - - // save the first result we find in the node and save it - for node, result := range results.AllCollectedData { - var nodeResult map[string]string - if err := json.Unmarshal(result, &nodeResult); err != nil { - return nil, errors.Wrap(err, "failed to marshal node results") - } - - for _, collectorResult := range nodeResult { - var collectedItems HostOSInfo - if err := json.Unmarshal([]byte(collectorResult), &collectedItems); err != nil { - return nil, errors.Wrap(err, "failed to marshal collector results") - } - - b, err := json.MarshalIndent(collectedItems, "", " ") - if err != nil { - return nil, errors.Wrap(err, "failed to marshal host os info") - } - output.SaveResult(c.BundlePath, fmt.Sprintf("host-collectors/system/%s/%s", node, HostInfoFileName), bytes.NewBuffer(b)) - } - } - - return output, nil -} diff --git a/pkg/loader/loader_test.go b/pkg/loader/loader_test.go index beb5feee..3080b369 100644 --- a/pkg/loader/loader_test.go +++ b/pkg/loader/loader_test.go @@ -30,11 +30,11 @@ func TestLoadingHelmTemplate_Succeeds(t *testing.T) { // Assert a few fields from the loaded troubleshoot specs assert.Equal(t, "redactor-spec-1", kinds.RedactorsV1Beta2[0].ObjectMeta.Name) assert.Equal(t, "REDACT SECOND TEXT PLEASE", kinds.RedactorsV1Beta2[0].Spec.Redactors[0].Removals.Values[0]) - assert.Equal(t, "sb-spec-1", kinds.SupportBundlesV1Beta2[0].Metadata.Name) - assert.Equal(t, "sb-spec-2", kinds.SupportBundlesV1Beta2[1].Metadata.Name) - assert.Equal(t, "sb-spec-3", kinds.SupportBundlesV1Beta2[2].Metadata.Name) - assert.Equal(t, false, kinds.SupportBundlesV1Beta2[0].Metadata.RunHostCollectorsInPod) - assert.Equal(t, true, kinds.SupportBundlesV1Beta2[2].Metadata.RunHostCollectorsInPod) + assert.Equal(t, "sb-spec-1", kinds.SupportBundlesV1Beta2[0].Name) + assert.Equal(t, "sb-spec-2", kinds.SupportBundlesV1Beta2[1].Name) + assert.Equal(t, "sb-spec-3", kinds.SupportBundlesV1Beta2[2].Name) + assert.Equal(t, false, kinds.SupportBundlesV1Beta2[0].Spec.RunHostCollectorsInPod) + assert.Equal(t, true, kinds.SupportBundlesV1Beta2[2].Spec.RunHostCollectorsInPod) assert.Equal(t, "wg-easy", kinds.SupportBundlesV1Beta2[1].Spec.Collectors[0].Logs.CollectorName) assert.Equal(t, "Node Count Check", kinds.PreflightsV1Beta2[0].Spec.Analyzers[0].NodeResources.CheckName) assert.Len(t, kinds.PreflightsV1Beta2[0].Spec.Collectors, 0) @@ -348,10 +348,8 @@ func TestLoadingMultidocsWithTroubleshootSpecs(t *testing.T) { Kind: "SupportBundle", APIVersion: "troubleshoot.sh/v1beta2", }, - Metadata: troubleshootv1beta2.SupportBundleMetadata{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-support-bundle", - }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-support-bundle", }, Spec: troubleshootv1beta2.SupportBundleSpec{ Collectors: []*troubleshootv1beta2.Collect{ @@ -630,10 +628,8 @@ func TestLoadingEmptySpec(t *testing.T) { Kind: "SupportBundle", APIVersion: "troubleshoot.sh/v1beta2", }, - Metadata: troubleshootv1beta2.SupportBundleMetadata{ - ObjectMeta: metav1.ObjectMeta{ - Name: "empty", - }, + ObjectMeta: metav1.ObjectMeta{ + Name: "empty", }, }, }, diff --git a/pkg/supportbundle/collect.go b/pkg/supportbundle/collect.go index a839ca0d..6cff3770 100644 --- a/pkg/supportbundle/collect.go +++ b/pkg/supportbundle/collect.go @@ -8,6 +8,7 @@ import ( "io" "os" "reflect" + "time" "github.com/pkg/errors" analyze "github.com/replicatedhq/troubleshoot/pkg/analyze" @@ -22,89 +23,52 @@ import ( "k8s.io/client-go/kubernetes" ) +type FilteredCollector struct { + Spec troubleshootv1beta2.HostCollect + Collector collect.HostCollector +} + func runHostCollectors(ctx context.Context, hostCollectors []*troubleshootv1beta2.HostCollect, additionalRedactors *troubleshootv1beta2.Redactor, bundlePath string, opts SupportBundleCreateOpts) (collect.CollectorResult, error) { - collectSpecs := make([]*troubleshootv1beta2.HostCollect, 0) - collectSpecs = append(collectSpecs, hostCollectors...) + collectSpecs := append([]*troubleshootv1beta2.HostCollect{}, hostCollectors...) + collectedData := make(map[string][]byte) - allCollectedData := make(map[string][]byte) - - var collectors []collect.HostCollector - for _, desiredCollector := range collectSpecs { - collector, ok := collect.GetHostCollector(desiredCollector, bundlePath) - if ok { - collectors = append(collectors, collector) - } + // Filter out excluded collectors + filteredCollectors, err := filterHostCollectors(ctx, collectSpecs, bundlePath, opts) + if err != nil { + return nil, err } - for _, collector := range collectors { - // TODO: Add context to host collectors - _, span := otel.Tracer(constants.LIB_TRACER_NAME).Start(ctx, collector.Title()) - span.SetAttributes(attribute.String("type", reflect.TypeOf(collector).String())) - - isExcluded, _ := collector.IsExcluded() - if isExcluded { - opts.ProgressChan <- fmt.Sprintf("[%s] Excluding host collector", collector.Title()) - span.SetAttributes(attribute.Bool(constants.EXCLUDED, true)) - span.End() - continue - } - - opts.ProgressChan <- fmt.Sprintf("[%s] Running host collector...", collector.Title()) - if opts.RunHostCollectorsInPod { - result, err := collector.RemoteCollect(opts.ProgressChan) - if err != nil { - // If the collector does not have a remote collector implementation, try to run it locally - if errors.Is(err, collect.ErrRemoteCollectorNotImplemented) { - result, err = collector.Collect(opts.ProgressChan) - if err != nil { - span.SetStatus(codes.Error, err.Error()) - opts.ProgressChan <- errors.Errorf("failed to run host collector: %s: %v", collector.Title(), err) - } - } else { - // If the collector has a remote collector implementation, but it failed to run, return the error - span.SetStatus(codes.Error, err.Error()) - opts.ProgressChan <- errors.Errorf("failed to run host collector: %s: %v", collector.Title(), err) + if opts.RunHostCollectorsInPod { + if err := checkRemoteCollectorRBAC(ctx, opts.KubernetesRestConfig, "Remote Host Collectors", opts.Namespace); err != nil { + if rbacErr, ok := err.(*RBACPermissionError); ok { + for _, forbiddenErr := range rbacErr.Forbidden { + opts.ProgressChan <- forbiddenErr } - } - // If the collector has a remote collector implementation, and it ran successfully, return the result - span.End() - for k, v := range result { - allCollectedData[k] = v - } - } else { - // If the collector does not enable run host collectors in pod, run it locally - result, err := collector.Collect(opts.ProgressChan) - if err != nil { - span.SetStatus(codes.Error, err.Error()) - opts.ProgressChan <- errors.Errorf("failed to run host collector: %s: %v", collector.Title(), err) - } - span.End() - for k, v := range result { - allCollectedData[k] = v + + if !opts.CollectWithoutPermissions { + return nil, collect.ErrInsufficientPermissionsToRun + } + } else { + return nil, err } } - } - - collectResult := allCollectedData - - globalRedactors := []*troubleshootv1beta2.Redact{} - if additionalRedactors != nil { - globalRedactors = additionalRedactors.Spec.Redactors + if err := collectRemoteHost(ctx, filteredCollectors, bundlePath, opts, collectedData); err != nil { + return nil, err + } + } else { + if err := collectHost(ctx, filteredCollectors, opts, collectedData); err != nil { + return nil, err + } } if opts.Redact { - _, span := otel.Tracer(constants.LIB_TRACER_NAME).Start(ctx, "Host collectors") - span.SetAttributes(attribute.String("type", "Redactors")) - err := collect.RedactResult(bundlePath, collectResult, globalRedactors) - if err != nil { - err = errors.Wrap(err, "failed to redact host collector results") - span.SetStatus(codes.Error, err.Error()) - return collectResult, err + globalRedactors := getGlobalRedactors(additionalRedactors) + if err := redactResults(ctx, bundlePath, collectedData, globalRedactors); err != nil { + return collectedData, err } - span.End() } - return collectResult, nil + return collectedData, nil } func runCollectors(ctx context.Context, collectors []*troubleshootv1beta2.Collect, additionalRedactors *troubleshootv1beta2.Redactor, bundlePath string, opts SupportBundleCreateOpts) (collect.CollectorResult, error) { @@ -253,3 +217,141 @@ func getAnalysisFile(analyzeResults []*analyze.AnalyzeResult) (io.Reader, error) return bytes.NewBuffer(analysis), nil } + +// collectRemoteHost runs remote host collectors sequentially +func collectRemoteHost(ctx context.Context, filteredCollectors []FilteredCollector, bundlePath string, opts SupportBundleCreateOpts, collectedData map[string][]byte) error { + opts.KubernetesRestConfig.QPS = constants.DEFAULT_CLIENT_QPS + opts.KubernetesRestConfig.Burst = constants.DEFAULT_CLIENT_BURST + opts.KubernetesRestConfig.UserAgent = fmt.Sprintf("%s/%s", constants.DEFAULT_CLIENT_USER_AGENT, version.Version()) + + // Run remote collectors sequentially + for _, c := range filteredCollectors { + collector := c.Collector + spec := c.Spec + + // Send progress event: starting the collector + opts.ProgressChan <- fmt.Sprintf("[%s] Running host collector...", collector.Title()) + + // Start a span for tracing + _, span := otel.Tracer(constants.LIB_TRACER_NAME).Start(ctx, collector.Title()) + span.SetAttributes(attribute.String("type", reflect.TypeOf(collector).String())) + + // Parameters for remote collection + params := &collect.RemoteCollectParams{ + ProgressChan: opts.ProgressChan, + HostCollector: &spec, + BundlePath: bundlePath, + ClientConfig: opts.KubernetesRestConfig, + Image: "replicated/troubleshoot:latest", + PullPolicy: "IfNotPresent", + Timeout: time.Duration(60 * time.Second), + LabelSelector: "", + NamePrefix: "host-remote", + Namespace: "default", + Title: collector.Title(), + } + + // Perform the collection + result, err := collect.RemoteHostCollect(ctx, *params) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + opts.ProgressChan <- fmt.Sprintf("[%s] Error: %v", collector.Title(), err) + return errors.Wrap(err, "failed to run remote host collector") + } + + // Send progress event: completed successfully + opts.ProgressChan <- fmt.Sprintf("[%s] Completed host collector", collector.Title()) + + // Aggregate the results + for k, v := range result { + collectedData[k] = v + } + + span.End() + } + return nil +} + +// collectHost runs host collectors sequentially +func collectHost(ctx context.Context, filteredCollectors []FilteredCollector, opts SupportBundleCreateOpts, collectedData map[string][]byte) error { + // Run local collectors sequentially + for _, c := range filteredCollectors { + collector := c.Collector + + // Send progress event: starting the collector + opts.ProgressChan <- fmt.Sprintf("[%s] Running host collector...", collector.Title()) + + // Start a span for tracing + _, span := otel.Tracer(constants.LIB_TRACER_NAME).Start(ctx, collector.Title()) + span.SetAttributes(attribute.String("type", reflect.TypeOf(collector).String())) + + // Run local collector sequentially + result, err := collector.Collect(opts.ProgressChan) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + opts.ProgressChan <- fmt.Sprintf("[%s] Error: %v", collector.Title(), err) + return errors.Wrap(err, "failed to run host collector") + } + + // Send progress event: completed successfully + opts.ProgressChan <- fmt.Sprintf("[%s] Completed host collector", collector.Title()) + + // Aggregate the results + for k, v := range result { + collectedData[k] = v + } + + span.End() + } + return nil +} + +func redactResults(ctx context.Context, bundlePath string, collectedData collect.CollectorResult, redactors []*troubleshootv1beta2.Redact) error { + _, span := otel.Tracer(constants.LIB_TRACER_NAME).Start(ctx, "Host collectors") + defer span.End() + + err := collect.RedactResult(bundlePath, collectedData, redactors) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + return errors.Wrap(err, "failed to redact host collector results") + } + return nil +} + +// getGlobalRedactors returns the global redactors from the support bundle spec +func getGlobalRedactors(additionalRedactors *troubleshootv1beta2.Redactor) []*troubleshootv1beta2.Redact { + if additionalRedactors != nil { + return additionalRedactors.Spec.Redactors + } + return []*troubleshootv1beta2.Redact{} +} + +// filterHostCollectors filters out excluded collectors and returns a list of collectors to run +func filterHostCollectors(ctx context.Context, collectSpecs []*troubleshootv1beta2.HostCollect, bundlePath string, opts SupportBundleCreateOpts) ([]FilteredCollector, error) { + var filteredCollectors []FilteredCollector + + for _, desiredCollector := range collectSpecs { + collector, ok := collect.GetHostCollector(desiredCollector, bundlePath) + _, span := otel.Tracer(constants.LIB_TRACER_NAME).Start(ctx, collector.Title()) + span.SetAttributes(attribute.String("type", reflect.TypeOf(collector).String())) + + if !ok { + return nil, collect.ErrHostCollectorNotFound + } + + isExcluded, _ := collector.IsExcluded() + if isExcluded { + opts.ProgressChan <- fmt.Sprintf("[%s] Excluding host collector", collector.Title()) + span.SetAttributes(attribute.Bool(constants.EXCLUDED, true)) + span.End() + continue + } + + filteredCollectors = append(filteredCollectors, FilteredCollector{ + Spec: *desiredCollector, + Collector: collector, + }) + } + + return filteredCollectors, nil +} diff --git a/pkg/supportbundle/load.go b/pkg/supportbundle/load.go index 6b61546c..bbbaf275 100644 --- a/pkg/supportbundle/load.go +++ b/pkg/supportbundle/load.go @@ -62,9 +62,7 @@ func ParseSupportBundle(doc []byte, followURI bool) (*troubleshootv1beta2.Suppor APIVersion: "troubleshoot.sh/v1beta2", Kind: "SupportBundle", }, - Metadata: troubleshootv1beta2.SupportBundleMetadata{ - ObjectMeta: collector.ObjectMeta, - }, + ObjectMeta: collector.ObjectMeta, Spec: troubleshootv1beta2.SupportBundleSpec{ Collectors: collector.Spec.Collectors, Analyzers: []*troubleshootv1beta2.Analyze{}, diff --git a/pkg/supportbundle/rbac.go b/pkg/supportbundle/rbac.go new file mode 100644 index 00000000..0ed58a9f --- /dev/null +++ b/pkg/supportbundle/rbac.go @@ -0,0 +1,72 @@ +package supportbundle + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + "github.com/replicatedhq/troubleshoot/pkg/collect" + authorizationv1 "k8s.io/api/authorization/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +// Custom error type for RBAC permission errors +type RBACPermissionError struct { + Forbidden []error +} + +func (e *RBACPermissionError) Error() string { + return fmt.Sprintf("insufficient permissions: %v", e.Forbidden) +} + +func (e *RBACPermissionError) HasErrors() bool { + return len(e.Forbidden) > 0 +} + +// checkRBAC checks if the current user has the necessary permissions to run the collectors +func checkRemoteCollectorRBAC(ctx context.Context, clientConfig *rest.Config, title string, namespace string) error { + client, err := kubernetes.NewForConfig(clientConfig) + if err != nil { + return errors.Wrap(err, "failed to create client from config") + } + + var forbidden []error + + spec := authorizationv1.SelfSubjectAccessReviewSpec{ + ResourceAttributes: &authorizationv1.ResourceAttributes{ + Namespace: namespace, + Verb: "create,delete", + Group: "", + Version: "", + Resource: "pods,configmap", + Subresource: "", + Name: "", + }, + NonResourceAttributes: nil, + } + + sar := &authorizationv1.SelfSubjectAccessReview{ + Spec: spec, + } + resp, err := client.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, sar, metav1.CreateOptions{}) + if err != nil { + return errors.Wrap(err, "failed to run subject review") + } + + if !resp.Status.Allowed { + forbidden = append(forbidden, collect.RBACError{ + DisplayName: title, + Namespace: spec.ResourceAttributes.Namespace, + Resource: spec.ResourceAttributes.Resource, + Verb: spec.ResourceAttributes.Verb, + }) + } + + if len(forbidden) > 0 { + return &RBACPermissionError{Forbidden: forbidden} + } + + return nil +} diff --git a/schemas/supportbundle-troubleshoot-v1beta2.json b/schemas/supportbundle-troubleshoot-v1beta2.json index 8c46f6b7..884ac10f 100644 --- a/schemas/supportbundle-troubleshoot-v1beta2.json +++ b/schemas/supportbundle-troubleshoot-v1beta2.json @@ -3194,6 +3194,9 @@ "exclude": { "oneOf": [{"type": "string"},{"type": "boolean"}] }, + "image": { + "type": "string" + }, "namespace": { "type": "string" }, @@ -19630,6 +19633,9 @@ } } }, + "runHostCollectorsInPod": { + "type": "boolean" + }, "uri": { "description": "URI optionally defines a location which is the source of this spec to allow updating of the spec at runtime", "type": "string" diff --git a/test/e2e/support-bundle/host_remote_collector_e2e_test.go b/test/e2e/support-bundle/host_remote_collector_e2e_test.go new file mode 100644 index 00000000..f8538e25 --- /dev/null +++ b/test/e2e/support-bundle/host_remote_collector_e2e_test.go @@ -0,0 +1,41 @@ +package e2e + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "testing" + + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/features" +) + +func TestHostRemoteCollector(t *testing.T) { + feature := features.New("Host OS Remote Collector Test"). + Assess("run support bundle command successfully", func(ctx context.Context, t *testing.T, c *envconf.Config) context.Context { + var out bytes.Buffer + supportbundleName := "host-os-remote-collector" + cmd := exec.CommandContext(ctx, sbBinary(), "spec/remoteHostCollectors.yaml", "--interactive=false", fmt.Sprintf("-o=%s", supportbundleName)) + cmd.Stdout = &out + err := cmd.Run() + if err != nil { + t.Fatalf("Failed to run the binary: %v", err) + } + + defer func() { + err := os.Remove(fmt.Sprintf("%s.tar.gz", supportbundleName)) + if err != nil { + t.Fatalf("Error removing file: %v", err) + } + }() + + // At this point, we only care that the binary ran successfully, no need to check folder contents. + t.Logf("Binary executed successfully: %s", out.String()) + + return ctx + }).Feature() + + testenv.Test(t, feature) +} diff --git a/test/e2e/support-bundle/spec/hostOSRemoteCollector.yaml b/test/e2e/support-bundle/spec/hostOSRemoteCollector.yaml index 15c9ae80..48b6e424 100644 --- a/test/e2e/support-bundle/spec/hostOSRemoteCollector.yaml +++ b/test/e2e/support-bundle/spec/hostOSRemoteCollector.yaml @@ -2,7 +2,7 @@ apiVersion: troubleshoot.sh/v1beta2 kind: SupportBundle metadata: name: sb - runHostCollectorsInPod: true # default is false spec: + runHostCollectorsInPod: true hostCollectors: - hostOS: {} diff --git a/test/e2e/support-bundle/spec/remoteHostCollectors.yaml b/test/e2e/support-bundle/spec/remoteHostCollectors.yaml new file mode 100644 index 00000000..698c1ec8 --- /dev/null +++ b/test/e2e/support-bundle/spec/remoteHostCollectors.yaml @@ -0,0 +1,34 @@ +apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: "remote-host-collectors" +spec: + runHostCollectorsInPod: true + hostCollectors: + - ipv4Interfaces: {} + - hostServices: {} + - cpu: {} + - hostOS: {} + - memory: {} + - blockDevices: {} + - kernelConfigs: {} + - copy: + collectorName: etc-resolv + path: /etc/resolv.conf + - dns: + collectorName: replicated-app-resolve + hostnames: + - replicated.app + - diskUsage: + collectorName: root-disk-usage + path: / + - diskUsage: + collectorName: tmp + path: /tmp + - http: + collectorName: get-replicated-app + get: + url: https://replicated.app + - run: + collectorName: uptime + command: uptime diff --git a/testdata/yamldocs/helm-template.yaml b/testdata/yamldocs/helm-template.yaml index 56cb856b..ae9b76ff 100644 --- a/testdata/yamldocs/helm-template.yaml +++ b/testdata/yamldocs/helm-template.yaml @@ -47,8 +47,8 @@ data: kind: SupportBundle metadata: name: sb-spec-3 - runHostCollectorsInPod: true spec: + runHostCollectorsInPod: true collectors: - logs: collectorName: wg-easy