diff --git a/cmd/troubleshoot/cli/root.go b/cmd/troubleshoot/cli/root.go index d64039d8..f98fb713 100644 --- a/cmd/troubleshoot/cli/root.go +++ b/cmd/troubleshoot/cli/root.go @@ -17,7 +17,7 @@ import ( func RootCmd() *cobra.Command { cmd := &cobra.Command{ Use: "support-bundle [url]", - Args: cobra.MinimumNArgs(1), + Args: cobra.MinimumNArgs(0), Short: "Generate a support bundle", Long: `A support bundle is an archive of files, output, metrics and state from a server that can be used to assist when troubleshooting a Kubernetes cluster.`, @@ -47,6 +47,8 @@ from a server that can be used to assist when troubleshooting a Kubernetes clust cmd.Flags().Bool("redact", true, "enable/disable default redactions") cmd.Flags().Bool("interactive", true, "enable/disable interactive mode") cmd.Flags().Bool("collect-without-permissions", true, "always generate a support bundle, even if it some require additional permissions") + cmd.Flags().StringSliceP("selector", "l", []string{"troubleshoot.io/kind=supportbundle-spec"}, "selector to filter on for loading additional support bundle specs found in secrets within the cluster") + cmd.Flags().Bool("load-cluster-specs", false, "enable/disable loading additional support bundle specs found in secrets within the cluster. required when no specs are provided on the command line") cmd.Flags().String("since-time", "", "force pod logs collectors to return logs after a specific date (RFC3339)") cmd.Flags().String("since", "", "force pod logs collectors to return logs newer than a relative duration like 5s, 2m, or 3h.") cmd.Flags().StringP("output", "o", "", "specify the output file path for the support bundle") diff --git a/cmd/troubleshoot/cli/run.go b/cmd/troubleshoot/cli/run.go index 6b1a9bca..a7f792a8 100644 --- a/cmd/troubleshoot/cli/run.go +++ b/cmd/troubleshoot/cli/run.go @@ -22,16 +22,27 @@ import ( "github.com/replicatedhq/troubleshoot/pkg/client/troubleshootclientset/scheme" troubleshootclientsetscheme "github.com/replicatedhq/troubleshoot/pkg/client/troubleshootclientset/scheme" "github.com/replicatedhq/troubleshoot/pkg/convert" - "github.com/replicatedhq/troubleshoot/pkg/docrewrite" "github.com/replicatedhq/troubleshoot/pkg/httputil" "github.com/replicatedhq/troubleshoot/pkg/k8sutil" + "github.com/replicatedhq/troubleshoot/pkg/logger" + "github.com/replicatedhq/troubleshoot/pkg/specs" "github.com/replicatedhq/troubleshoot/pkg/supportbundle" "github.com/spf13/viper" spin "github.com/tj/go-spin" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" ) +const ( + SupportBundleSecretKey = "support-bundle-spec" +) + func runTroubleshoot(v *viper.Viper, arg []string) error { + if v.GetBool("load-cluster-specs") == false && len(arg) < 1 { + return errors.New("flag load-cluster-specs must be set if no specs are provided on the command line") + } + interactive := v.GetBool("interactive") && isatty.IsTerminal(os.Stdout.Fd()) if interactive { @@ -71,18 +82,17 @@ func runTroubleshoot(v *viper.Viper, arg []string) error { var mainBundle *troubleshootv1beta2.SupportBundle troubleshootclientsetscheme.AddToScheme(scheme.Scheme) - decode := scheme.Codecs.UniversalDeserializer().Decode additionalRedactors := &troubleshootv1beta2.Redactor{} for i, v := range arg { collectorContent, err := supportbundle.LoadSupportBundleSpec(v) if err != nil { - return errors.Wrap(err, "failed to load collector spec") + return errors.Wrap(err, "failed to load support bundle spec") } multidocs := strings.Split(string(collectorContent), "\n---\n") supportBundle, err := supportbundle.ParseSupportBundleFromDoc([]byte(multidocs[0])) if err != nil { - return errors.Wrap(err, "failed to parse collector") + return errors.Wrap(err, "failed to parse support bundle spec") } if i == 0 { @@ -91,24 +101,73 @@ func runTroubleshoot(v *viper.Viper, arg []string) error { mainBundle = supportbundle.ConcatSpec(mainBundle, supportBundle) } - 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...) + parsedRedactors, err := supportbundle.ParseRedactorsFromSpec(multidocs) + if err != nil { + return errors.Wrap(err, "failed to parse redactors from doc") } + additionalRedactors.Spec.Redactors = append(additionalRedactors.Spec.Redactors, parsedRedactors...) + } + + if v.GetBool("load-cluster-specs") { + labelSelector := strings.Join(v.GetStringSlice("selector"), ",") + + parsedSelector, err := labels.Parse(labelSelector) + if err != nil { + return errors.Wrap(err, "unable to parse selector") + } + + namespace := "" + if v.GetString("namespace") != "" { + namespace = v.GetString("namespace") + } + + config, err := k8sutil.GetRESTConfig() + if err != nil { + return errors.Wrap(err, "failed to convert kube flags to rest config") + } + + client, err := kubernetes.NewForConfig(config) + if err != nil { + return errors.Wrap(err, "failed to convert create k8s client") + } + + bundlesFromSecrets, err := specs.LoadFromSecretMatchingLabel(client, parsedSelector.String(), namespace, SupportBundleSecretKey) + if err != nil { + logger.Printf("failed to load support bundle spec from secrets: %s", err) + } + + if bundlesFromSecrets != nil { + for _, bundle := range bundlesFromSecrets { + multidocs := strings.Split(string(bundle), "\n---\n") + parsedBundlesFromSecrets, err := supportbundle.ParseSupportBundleFromDoc([]byte(multidocs[0])) + if err != nil { + logger.Printf("failed to parse support bundle spec: %s", err) + continue + } + + if mainBundle == nil { + mainBundle = parsedBundlesFromSecrets + } else { + supportbundle.ConcatSpec(mainBundle, parsedBundlesFromSecrets) + } + + parsedRedactors, err := supportbundle.ParseRedactorsFromSpec(multidocs) + if err != nil { + logger.Printf("failed to parse redactors from doc: %s", err) + continue + } + additionalRedactors.Spec.Redactors = append(additionalRedactors.Spec.Redactors, parsedRedactors...) + } + } + if mainBundle == nil { + return errors.New("no specs found in cluster") + } + } + + if mainBundle == nil { + return errors.New("no support bundle specs provided to run") + } else if mainBundle.Spec.Collectors == nil && mainBundle.Spec.HostCollectors == nil { + return errors.New("no collectors specified in support bundle") } for idx, redactor := range v.GetStringSlice("redactors") { diff --git a/pkg/specs/secrets.go b/pkg/specs/secrets.go index 8106fe81..a3d5b401 100644 --- a/pkg/specs/secrets.go +++ b/pkg/specs/secrets.go @@ -5,6 +5,7 @@ import ( "github.com/pkg/errors" "github.com/replicatedhq/troubleshoot/pkg/k8sutil" + "github.com/replicatedhq/troubleshoot/pkg/logger" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" ) @@ -32,3 +33,23 @@ func LoadFromSecret(namespace string, secretName string, key string) ([]byte, er return spec, nil } + +func LoadFromSecretMatchingLabel(client kubernetes.Interface, labelSelector string, namespace string, key string) ([]string, error) { + var secretsMatchingKey []string + + secrets, err := client.CoreV1().Secrets(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: labelSelector}) + if err != nil { + return nil, errors.Wrap(err, "failed to search for secrets in the cluster") + } + + for _, secret := range secrets.Items { + spec, ok := secret.Data[key] + if !ok { + logger.Printf("expected key of %s not found in secret %s, skipping\n", key, secret.Name) + continue + } + secretsMatchingKey = append(secretsMatchingKey, string(spec)) + } + + return secretsMatchingKey, nil +} diff --git a/pkg/specs/secrets_test.go b/pkg/specs/secrets_test.go new file mode 100644 index 00000000..00b97a08 --- /dev/null +++ b/pkg/specs/secrets_test.go @@ -0,0 +1,400 @@ +package specs + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + testclient "k8s.io/client-go/kubernetes/fake" +) + +func Test_LoadFromSecretMatchingLabel(t *testing.T) { + type args struct { + ctx context.Context + client kubernetes.Interface + } + tests := []struct { + name string + supportBundleSecrets []corev1.Secret + want []string + wantErr bool + }{ + { + name: "support bundle secret with matching label and key", + supportBundleSecrets: []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret", + Namespace: "default", + Labels: map[string]string{ + "troubleshoot.io/kind": "supportbundle-spec", + }, + }, + Data: map[string][]byte{ + "support-bundle-spec": []byte(`apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: test +spec: + collectors: + - runPod: + name: "run-ping" + namespace: default + podSpec: + containers: + - name: run-ping + image: busybox:1 + command: ["ping"] + args: ["-w", "5", "www.google.com"]`), + }, + }, + }, + want: []string{ + `apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: test +spec: + collectors: + - runPod: + name: "run-ping" + namespace: default + podSpec: + containers: + - name: run-ping + image: busybox:1 + command: ["ping"] + args: ["-w", "5", "www.google.com"]`, + }, + }, + { + name: "support bundle secret with missing label", + supportBundleSecrets: []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "support-bundle-spec": []byte(`apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: test +spec: + collectors: + - data: + name: static/data.txt + data: | + static data`), + }, + }, + }, + want: []string(nil), + }, + { + name: "support bundle secret with matching label but wrong key", + supportBundleSecrets: []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "support-bundle-specc": []byte(`apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: test +spec: + collectors: + - data: + name: static/data.txt + data: | + static data`), + }, + }, + }, + want: []string(nil), + }, + { + name: "multiple support bundle secrets in the same namespace with matching label and key", + supportBundleSecrets: []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret", + Namespace: "default", + Labels: map[string]string{ + "troubleshoot.io/kind": "supportbundle-spec", + }, + }, + Data: map[string][]byte{ + "support-bundle-spec": []byte(`apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: cluster-info +spec: + collectors: + - clusterInfo: {}`), + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret-2", + Namespace: "default", + Labels: map[string]string{ + "troubleshoot.io/kind": "supportbundle-spec", + }, + }, + Data: map[string][]byte{ + "support-bundle-spec": []byte(`apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: cluster-resources +spec: + collectors: + - clusterResources: {}`), + }, + }, + }, + want: []string{ + `apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: cluster-info +spec: + collectors: + - clusterInfo: {}`, + `apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: cluster-resources +spec: + collectors: + - clusterResources: {}`, + }, + }, + { + name: "multiple support bundle secrets in different namespaces with matching label and key", + supportBundleSecrets: []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret", + Namespace: "some-namespace", + Labels: map[string]string{ + "troubleshoot.io/kind": "supportbundle-spec", + }, + }, + Data: map[string][]byte{ + "support-bundle-spec": []byte(`apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: cluster-info +spec: + collectors: + - clusterInfo: {}`), + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret-2", + Namespace: "some-namespace-2", + Labels: map[string]string{ + "troubleshoot.io/kind": "supportbundle-spec", + }, + }, + Data: map[string][]byte{ + "support-bundle-spec": []byte(`apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: cluster-resources +spec: + collectors: + - clusterResources: {}`), + }, + }, + }, + want: []string{ + `apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: cluster-info +spec: + collectors: + - clusterInfo: {}`, + `apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: cluster-resources +spec: + collectors: + - clusterResources: {}`, + }, + }, + { + name: "multiple support bundle secrets in different namespaces but only one with correct label and key", + supportBundleSecrets: []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret", + Namespace: "some-namespace", + Labels: map[string]string{ + "troubleshoot.io/kind": "supportbundle-spec-wrong", + }, + }, + Data: map[string][]byte{ + "support-bundle-spec-wrong": []byte(`apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: cluster-info +spec: + collectors: + - clusterInfo: {}`), + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret-2", + Namespace: "some-namespace-2", + Labels: map[string]string{ + "troubleshoot.io/kind": "supportbundle-spec", + }, + }, + Data: map[string][]byte{ + "support-bundle-spec": []byte(`apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: cluster-resources +spec: + collectors: + - clusterResources: {}`), + }, + }, + }, + want: []string{ + `apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: cluster-resources +spec: + collectors: + - clusterResources: {}`, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + client := testclient.NewSimpleClientset() + for _, secret := range tt.supportBundleSecrets { + _, err := client.CoreV1().Secrets(secret.Namespace).Create(ctx, &secret, metav1.CreateOptions{}) + require.NoError(t, err) + } + got, err := LoadFromSecretMatchingLabel(client, "troubleshoot.io/kind=supportbundle-spec", "", "support-bundle-spec") + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} + +func TestUserProvidedNamespace_LoadFromSecretMatchingLabel(t *testing.T) { + type args struct { + ctx context.Context + client kubernetes.Interface + } + tests := []struct { + name string + supportBundleSecrets []corev1.Secret + want []string + wantErr bool + }{ + { + name: "support bundle secret with matching label and key in user provided namespace", + supportBundleSecrets: []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret", + Namespace: "some-namespace", + Labels: map[string]string{ + "troubleshoot.io/kind": "supportbundle-spec", + }, + }, + Data: map[string][]byte{ + "support-bundle-spec": []byte(`apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: test +spec: + collectors: + - data: + name: static/data.txt + data: | + static data`), + }, + }, + }, + want: []string{ + `apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: test +spec: + collectors: + - data: + name: static/data.txt + data: | + static data`, + }, + }, + { + name: "support bundle secret with matching label and key outside of user provided namespace", + supportBundleSecrets: []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret", + Namespace: "not-your-namespace", + Labels: map[string]string{ + "troubleshoot.io/kind": "supportbundle-spec", + }, + }, + Data: map[string][]byte{ + "support-bundle-spec": []byte(`apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: test +spec: + collectors: + - data: + name: static/data.txt + data: | + static data`), + }, + }, + }, + want: []string(nil), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + client := testclient.NewSimpleClientset() + for _, secret := range tt.supportBundleSecrets { + _, err := client.CoreV1().Secrets(secret.Namespace).Create(ctx, &secret, metav1.CreateOptions{}) + require.NoError(t, err) + } + got, err := LoadFromSecretMatchingLabel(client, "troubleshoot.io/kind=supportbundle-spec", "some-namespace", "support-bundle-spec") + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} diff --git a/pkg/supportbundle/load.go b/pkg/supportbundle/load.go index f3857947..9d7ca456 100644 --- a/pkg/supportbundle/load.go +++ b/pkg/supportbundle/load.go @@ -219,3 +219,30 @@ func loadSpecFromURL(arg string) ([]byte, error) { return body, nil } } + +func ParseRedactorsFromSpec(docs []string) ([]*troubleshootv1beta2.Redact, error) { + var redactors []*troubleshootv1beta2.Redact + + decode := scheme.Codecs.UniversalDeserializer().Decode + + for i, additionalDoc := range docs { + if i == 0 { + continue + } + additionalDoc, err := docrewrite.ConvertToV1Beta2([]byte(additionalDoc)) + if err != nil { + return nil, errors.Wrap(err, "failed to convert to v1beta2") + } + obj, _, err := decode(additionalDoc, nil, nil) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse additional doc %d", i) + } + multidocRedactors, ok := obj.(*troubleshootv1beta2.Redactor) + if !ok { + continue + } + redactors = append(redactors, multidocRedactors.Spec.Redactors...) + } + + return redactors, nil +} diff --git a/pkg/supportbundle/supportbundle_test.go b/pkg/supportbundle/supportbundle_test.go index 821f28fd..d29891ac 100644 --- a/pkg/supportbundle/supportbundle_test.go +++ b/pkg/supportbundle/supportbundle_test.go @@ -7,40 +7,40 @@ import ( func Test_LoadAndConcatSpec(t *testing.T) { - bundle1doc,err := LoadSupportBundleSpec("test/supportbundle1.yaml") - if err != nil { - t.Error("couldn't load bundle1 from file") - } + bundle1doc, err := LoadSupportBundleSpec("test/supportbundle1.yaml") + if err != nil { + t.Error("couldn't load bundle1 from file") + } - bundle2doc,err := LoadSupportBundleSpec("test/supportbundle2.yaml") - if err != nil { - t.Error("couldn't load bundle2 from file") - } + bundle2doc, err := LoadSupportBundleSpec("test/supportbundle2.yaml") + if err != nil { + t.Error("couldn't load bundle2 from file") + } - bundle1,err := ParseSupportBundleFromDoc(bundle1doc) - if err != nil { - t.Error("couldn't parse bundle 1") - } + bundle1, err := ParseSupportBundleFromDoc(bundle1doc) + if err != nil { + t.Error("couldn't parse bundle 1") + } - bundle2,err := ParseSupportBundleFromDoc(bundle2doc) - if err != nil { - t.Error("couldn't parse bundle 2") - } + bundle2, err := ParseSupportBundleFromDoc(bundle2doc) + if err != nil { + t.Error("couldn't parse bundle 2") + } - fulldoc,err := LoadSupportBundleSpec("test/completebundle.yaml") - if err != nil { - t.Error("couldn't load full bundle from file") - } + fulldoc, err := LoadSupportBundleSpec("test/completebundle.yaml") + if err != nil { + t.Error("couldn't load full bundle from file") + } - fullbundle,err := ParseSupportBundleFromDoc(fulldoc) - if err != nil { - t.Error("couldn't parse full bundle") - } + fullbundle, err := ParseSupportBundleFromDoc(fulldoc) + if err != nil { + t.Error("couldn't parse full bundle") + } - bundle3 := ConcatSpec(bundle1,bundle2) + bundle3 := ConcatSpec(bundle1, bundle2) - if reflect.DeepEqual(fullbundle, bundle3) == false { - t.Error("Full bundle and concatenated bundle are not the same.") - } + if reflect.DeepEqual(fullbundle, bundle3) == false { + t.Error("Full bundle and concatenated bundle are not the same.") + } }