diff --git a/.gitignore b/.gitignore index 6249328eb..61ca6e377 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,10 @@ *.dylib bin .DS_Store + +# npm generated files node_modules/ +package*.json # Test binary, build with `go test -c` *.test diff --git a/bin/watch.js b/bin/watch.js index 45a71b3d5..b2d535661 100755 --- a/bin/watch.js +++ b/bin/watch.js @@ -2,19 +2,6 @@ const gri = require('gaze-run-interrupt'); -const binList = [ - // 'bin/analyze', - // 'bin/preflight', - 'bin/support-bundle', - // 'bin/collect' -] -const makeList = [ - // 'analyze', - // 'preflight', - 'support-bundle', - // 'collect' -] - const commands = [ // { // command: 'rm', @@ -22,7 +9,7 @@ const commands = [ // }, { command: 'make', - args: makeList, + args: ['build'], }, ]; diff --git a/bin/watchrsync.js b/bin/watchrsync.js index 640cb1014..a77826a39 100755 --- a/bin/watchrsync.js +++ b/bin/watchrsync.js @@ -16,12 +16,6 @@ const binList = [ 'bin/support-bundle', // 'bin/collect' ] -const makeList = [ - // 'analyze', - // 'preflight', - 'support-bundle', - // 'collect' -] const commands = [ // { @@ -30,7 +24,7 @@ const commands = [ // }, { command: 'make', - args: makeList, + args: ['build'], }, ]; diff --git a/cmd/preflight/cli/oci_fetch.go b/cmd/preflight/cli/oci_fetch.go index f9bd147aa..7dbc8a175 100644 --- a/cmd/preflight/cli/oci_fetch.go +++ b/cmd/preflight/cli/oci_fetch.go @@ -2,9 +2,12 @@ package cli import ( "fmt" + "strings" + "github.com/replicatedhq/troubleshoot/pkg/logger" "github.com/replicatedhq/troubleshoot/pkg/oci" "github.com/spf13/cobra" + "github.com/spf13/viper" ) func OciFetchCmd() *cobra.Command { @@ -12,6 +15,13 @@ func OciFetchCmd() *cobra.Command { Use: "oci-fetch [URI]", Args: cobra.MinimumNArgs(1), Short: "Fetch a preflight from an OCI registry and print it to standard out", + PreRun: func(cmd *cobra.Command, args []string) { + v := viper.GetViper() + v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + v.BindPFlags(cmd.Flags()) + + logger.SetupLogger(v) + }, RunE: func(cmd *cobra.Command, args []string) error { uri := args[0] data, err := oci.PullPreflightFromOCI(uri) @@ -22,5 +32,9 @@ func OciFetchCmd() *cobra.Command { return nil }, } + + // Initialize klog flags + logger.InitKlogFlags(cmd) + return cmd } diff --git a/cmd/preflight/cli/root.go b/cmd/preflight/cli/root.go index 10070b1c1..19f8b81d9 100644 --- a/cmd/preflight/cli/root.go +++ b/cmd/preflight/cli/root.go @@ -49,7 +49,7 @@ that a cluster meets the requirements to run an application.`, } err = preflight.RunPreflights(v.GetBool("interactive"), v.GetString("output"), v.GetString("format"), args) - if v.GetBool("debug") || v.IsSet("v") { + if !v.GetBool("dry-run") && (v.GetBool("debug") || v.IsSet("v")) { fmt.Printf("\n%s", traces.GetExporterInstance().GetSummary()) } diff --git a/cmd/troubleshoot/cli/root.go b/cmd/troubleshoot/cli/root.go index a54c928ec..f02712b7d 100644 --- a/cmd/troubleshoot/cli/root.go +++ b/cmd/troubleshoot/cli/root.go @@ -45,7 +45,7 @@ from a server that can be used to assist when troubleshooting a Kubernetes clust } err = runTroubleshoot(v, args) - if v.GetBool("debug") || v.IsSet("v") { + if !v.IsSet("dry-run") && (v.GetBool("debug") || v.IsSet("v")) { fmt.Printf("\n%s", traces.GetExporterInstance().GetSummary()) } @@ -74,6 +74,7 @@ from a server that can be used to assist when troubleshooting a Kubernetes clust 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") cmd.Flags().Bool("debug", false, "enable debug logging. This is equivalent to --v=0") + cmd.Flags().Bool("dry-run", false, "print support bundle spec without collecting anything") // 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") @@ -81,7 +82,7 @@ from a server that can be used to assist when troubleshooting a Kubernetes clust // `no-uri` references the `followURI` functionality where we can use an upstream spec when creating a support bundle // This flag makes sure we can also disable this and fall back to the default spec. - cmd.Flags().Bool("no-uri", false, "When this flag is used, Troubleshoot does not attempt to retrieve the bundle referenced by the uri: field in the spec.`") + cmd.Flags().Bool("no-uri", false, "When this flag is used, Troubleshoot does not attempt to retrieve the spec referenced by the uri: field`") k8sutil.AddFlags(cmd.Flags()) diff --git a/cmd/troubleshoot/cli/run.go b/cmd/troubleshoot/cli/run.go index 45077c5de..bed7c9e35 100644 --- a/cmd/troubleshoot/cli/run.go +++ b/cmd/troubleshoot/cli/run.go @@ -9,7 +9,6 @@ import ( "os" "os/signal" "path/filepath" - "strings" "sync" "time" @@ -17,27 +16,65 @@ import ( "github.com/fatih/color" "github.com/mattn/go-isatty" "github.com/pkg/errors" - privSpecs "github.com/replicatedhq/troubleshoot/internal/specs" + "github.com/replicatedhq/troubleshoot/internal/specs" + "github.com/replicatedhq/troubleshoot/internal/util" analyzer "github.com/replicatedhq/troubleshoot/pkg/analyze" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" + "github.com/replicatedhq/troubleshoot/pkg/collect" + "github.com/replicatedhq/troubleshoot/pkg/constants" "github.com/replicatedhq/troubleshoot/pkg/convert" "github.com/replicatedhq/troubleshoot/pkg/httputil" "github.com/replicatedhq/troubleshoot/pkg/k8sutil" "github.com/replicatedhq/troubleshoot/pkg/loader" "github.com/replicatedhq/troubleshoot/pkg/supportbundle" + "github.com/replicatedhq/troubleshoot/pkg/types" "github.com/spf13/viper" spin "github.com/tj/go-spin" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/klog/v2" ) -func runTroubleshoot(v *viper.Viper, arg []string) error { +func runTroubleshoot(v *viper.Viper, args []string) error { ctx := context.Background() - if !v.GetBool("load-cluster-specs") && len(arg) < 1 { + if !v.GetBool("load-cluster-specs") && len(args) < 1 { return errors.New("flag load-cluster-specs must be set if no specs are provided on the command line") } + restConfig, err := k8sutil.GetRESTConfig() + if err != nil { + return errors.Wrap(err, "failed to convert kube flags to rest config") + } + + client, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return errors.Wrap(err, "failed to create kubernetes client") + } + + mainBundle, additionalRedactors, err := loadSpecs(ctx, args, client) + if err != nil { + return err + } + + // For --dry-run, we want to print the yaml and exit + if v.GetBool("dry-run") { + k := loader.TroubleshootKinds{ + SupportBundlesV1Beta2: []troubleshootv1beta2.SupportBundle{*mainBundle}, + } + // If we have redactors, add them to the temp kinds object + if len(additionalRedactors.Spec.Redactors) > 0 { + k.RedactorsV1Beta2 = []troubleshootv1beta2.Redactor{*additionalRedactors} + } + + out, err := k.ToYaml() + if err != nil { + return types.NewExitCodeError(constants.EXIT_CODE_CATCH_ALL, errors.Wrap(err, "failed to convert specs to yaml")) + } + fmt.Printf("%s", out) + return nil + } + interactive := v.GetBool("interactive") && isatty.IsTerminal(os.Stdout.Fd()) if interactive { @@ -55,11 +92,6 @@ func runTroubleshoot(v *viper.Viper, arg []string) error { os.Exit(0) }() - restConfig, err := k8sutil.GetRESTConfig() - if err != nil { - return errors.Wrap(err, "failed to convert kube flags to rest config") - } - var sinceTime *time.Time if v.GetString("since-time") != "" || v.GetString("since") != "" { sinceTime, err = parseTimeFlags(v) @@ -74,67 +106,6 @@ func runTroubleshoot(v *viper.Viper, arg []string) error { }) } - var mainBundle *troubleshootv1beta2.SupportBundle - - additionalRedactors := &troubleshootv1beta2.Redactor{} - - // Defining `v` below will render using `v` in reference to Viper unusable. - // Therefore refactoring `v` to `val` will make sure we can still use it. - for _, val := range arg { - - collectorContent, err := supportbundle.LoadSupportBundleSpec(val) - if err != nil { - return errors.Wrap(err, "failed to load support bundle spec") - } - multidocs := strings.Split(string(collectorContent), "\n---\n") - // Referencing `ParseSupportBundle with a secondary arg of `no-uri` - // Will make sure we can enable or disable the use of the `Spec.uri` field for an upstream spec. - // This change will not have an impact on KOTS' usage of `ParseSupportBundle` - // As Kots uses `load.go` directly. - supportBundle, err := supportbundle.ParseSupportBundle([]byte(multidocs[0]), !v.GetBool("no-uri")) - if err != nil { - return errors.Wrap(err, "failed to parse support bundle spec") - } - - mainBundle = supportbundle.ConcatSpec(mainBundle, supportBundle) - - parsedRedactors, err := supportbundle.ParseRedactorsFromDocs(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") { - kinds, err := loadClusterSpecs(ctx, v) - if err != nil { - return err - } - if len(kinds.SupportBundlesV1Beta2) == 0 { - return errors.New("no support bundle specs found in cluster") - } - for _, sb := range kinds.SupportBundlesV1Beta2 { - sb := sb // Why? https://golang.org/doc/faq#closures_and_goroutines - mainBundle = supportbundle.ConcatSpec(mainBundle, &sb) - } - - for _, redactor := range kinds.RedactorsV1Beta2 { - additionalRedactors.Spec.Redactors = append(additionalRedactors.Spec.Redactors, redactor.Spec.Redactors...) - } - } - - 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") - } - - redactors, err := supportbundle.GetRedactorsFromURIs(v.GetStringSlice("redactors")) - if err != nil { - return errors.Wrap(err, "failed to get redactors") - } - additionalRedactors.Spec.Redactors = append(additionalRedactors.Spec.Redactors, redactors...) - var wg sync.WaitGroup collectorCB := func(c chan interface{}, msg string) { c <- msg } progressChan := make(chan interface{}) @@ -261,18 +232,103 @@ the %s Admin Console to begin analysis.` return nil } -func loadClusterSpecs(ctx context.Context, v *viper.Viper) (*loader.TroubleshootKinds, error) { - config, err := k8sutil.GetRESTConfig() - if err != nil { - return nil, errors.Wrap(err, "failed to convert kube flags to rest config") +func loadSupportBundleSpecsFromURIs(ctx context.Context, kinds *loader.TroubleshootKinds) (*loader.TroubleshootKinds, error) { + remoteRawSpecs := []string{} + for _, s := range kinds.SupportBundlesV1Beta2 { + if s.Spec.Uri != "" && util.IsURL(s.Spec.Uri) { + // We are using LoadSupportBundleSpec function here since it handles prompting + // users to accept insecure connections + // There is an opportunity to refactor this code in favour of the Loader APIs + rawSpec, err := supportbundle.LoadSupportBundleSpec(s.Spec.Uri) + if err != nil { + return nil, errors.Wrapf(err, "failed to load support bundle from URI %q", s.Spec.Uri) + } + remoteRawSpecs = append(remoteRawSpecs, string(rawSpec)) + } } - client, err := kubernetes.NewForConfig(config) + return loader.LoadSpecs(ctx, loader.LoadOptions{ + RawSpecs: remoteRawSpecs, + }) +} + +func loadSpecs(ctx context.Context, args []string, client kubernetes.Interface) (*troubleshootv1beta2.SupportBundle, *troubleshootv1beta2.Redactor, error) { + // Append redactor uris to the args + allArgs := append(args, viper.GetStringSlice("redactors")...) + kinds, err := specs.LoadFromCLIArgs(ctx, client, allArgs, viper.GetViper()) if err != nil { - return nil, errors.Wrap(err, "failed to convert create k8s client") + return nil, nil, err + } + + // Load additional specs from support bundle URIs + if !viper.GetBool("no-uri") { + moreKinds, err := loadSupportBundleSpecsFromURIs(ctx, kinds) + if err != nil { + return nil, nil, err + } + kinds.Add(moreKinds) + } + + // Check if we have any collectors to run in the troubleshoot specs + // TODO: Do we use the RemoteCollectors anymore? + if len(kinds.CollectorsV1Beta2) == 0 && + len(kinds.HostCollectorsV1Beta2) == 0 && + len(kinds.SupportBundlesV1Beta2) == 0 { + return nil, nil, errors.New("no collectors specified to run") + } + + // Merge specs + // We need to add the default type information to the support bundle spec + // since by default these fields would be empty + mainBundle := &troubleshootv1beta2.SupportBundle{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "troubleshoot.sh/v1beta2", + Kind: "SupportBundle", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "merged-support-bundle-spec", + }, + } + for _, sb := range kinds.SupportBundlesV1Beta2 { + sb := sb + mainBundle = supportbundle.ConcatSpec(mainBundle, &sb) + } + + for _, c := range kinds.CollectorsV1Beta2 { + mainBundle.Spec.Collectors = append(mainBundle.Spec.Collectors, c.Spec.Collectors...) + } + + for _, hc := range kinds.HostCollectorsV1Beta2 { + mainBundle.Spec.HostCollectors = append(mainBundle.Spec.HostCollectors, hc.Spec.Collectors...) + } + + // Ensure cluster info and cluster resources collectors are in the merged spec + // We need to add them here so when we --dry-run, these collectors are included. + // supportbundle.runCollectors duplicates this bit. We'll need to refactor it out later + // when its clearer what other code depends on this logic e.g KOTS + mainBundle.Spec.Collectors = collect.EnsureCollectorInList( + mainBundle.Spec.Collectors, + troubleshootv1beta2.Collect{ClusterInfo: &troubleshootv1beta2.ClusterInfo{}}, + ) + mainBundle.Spec.Collectors = collect.EnsureCollectorInList( + mainBundle.Spec.Collectors, + troubleshootv1beta2.Collect{ClusterResources: &troubleshootv1beta2.ClusterResources{}}, + ) + + additionalRedactors := &troubleshootv1beta2.Redactor{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "troubleshoot.sh/v1beta2", + Kind: "Redactor", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "merged-redactors-spec", + }, + } + for _, r := range kinds.RedactorsV1Beta2 { + additionalRedactors.Spec.Redactors = append(additionalRedactors.Spec.Redactors, r.Spec.Redactors...) } - return privSpecs.LoadFromCluster(ctx, client, v.GetStringSlice("selector"), v.GetString("namespace")) + return mainBundle, additionalRedactors, nil } func parseTimeFlags(v *viper.Viper) (*time.Time, error) { diff --git a/cmd/troubleshoot/cli/run_test.go b/cmd/troubleshoot/cli/run_test.go new file mode 100644 index 000000000..a02e12c0d --- /dev/null +++ b/cmd/troubleshoot/cli/run_test.go @@ -0,0 +1,51 @@ +package cli + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/replicatedhq/troubleshoot/pkg/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_loadSupportBundleSpecsFromURIs(t *testing.T) { + // Run a webserver to serve the spec + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(` +apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: sb-2 +spec: + collectors: + - clusterInfo: {}`)) + })) + defer srv.Close() + + orig := ` +apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: sb-1 +spec: + uri: ` + srv.URL + ` + collectors: + - configMap: + name: kube-root-ca.crt + namespace: default +` + + ctx := context.Background() + kinds, err := loader.LoadSpecs(ctx, loader.LoadOptions{RawSpec: orig}) + require.NoError(t, err) + require.NotNil(t, kinds) + + moreKinds, err := loadSupportBundleSpecsFromURIs(ctx, kinds) + require.NoError(t, err) + + require.Len(t, moreKinds.SupportBundlesV1Beta2, 1) + assert.NotNil(t, moreKinds.SupportBundlesV1Beta2[0].Spec.Collectors[0].ClusterInfo) +} diff --git a/internal/specs/configmaps.go b/internal/specs/configmaps.go index d587b7fc2..485775ae1 100644 --- a/internal/specs/configmaps.go +++ b/internal/specs/configmaps.go @@ -9,22 +9,17 @@ import ( "k8s.io/klog/v2" ) -func LoadFromConfigMap(ctx context.Context, client kubernetes.Interface, ns string, name string, key string) ([]byte, error) { +func LoadFromConfigMap(ctx context.Context, client kubernetes.Interface, ns string, name string) (map[string]string, error) { foundConfigMap, err := client.CoreV1().ConfigMaps(ns).Get(ctx, name, metav1.GetOptions{}) if err != nil { return nil, errors.Wrap(err, "failed to get configmap") } - spec, ok := foundConfigMap.Data[key] - if !ok { - return nil, errors.Errorf("spec not found in configmap %s", name) - } - - klog.V(1).InfoS("Loaded spec from config map", "name", - foundConfigMap.Name, "namespace", foundConfigMap.Namespace, "data key", key, + klog.V(1).InfoS("Loaded data from config map", "name", + foundConfigMap.Name, "namespace", foundConfigMap.Namespace, ) - return []byte(spec), nil + return foundConfigMap.Data, nil } func LoadFromConfigMapMatchingLabel(ctx context.Context, client kubernetes.Interface, label string, ns string, key string) ([]string, error) { diff --git a/internal/specs/secrets.go b/internal/specs/secrets.go index 455c827af..0d517446e 100644 --- a/internal/specs/secrets.go +++ b/internal/specs/secrets.go @@ -9,21 +9,13 @@ import ( "k8s.io/klog/v2" ) -func LoadFromSecret(ctx context.Context, client kubernetes.Interface, ns string, name string, key string) ([]byte, error) { +func LoadFromSecret(ctx context.Context, client kubernetes.Interface, ns string, name string) (map[string][]byte, error) { foundSecret, err := client.CoreV1().Secrets(ns).Get(ctx, name, metav1.GetOptions{}) if err != nil { return nil, errors.Wrap(err, "failed to get secret") } - spec, ok := foundSecret.Data[key] - if !ok { - return nil, errors.Errorf("spec not found in secret %s", name) - } - - klog.V(1).InfoS("Loaded spec from secret", "name", - foundSecret.Name, "namespace", foundSecret.Namespace, "data key", key, - ) - return spec, nil + return foundSecret.Data, nil } func LoadFromSecretMatchingLabel(ctx context.Context, client kubernetes.Interface, label string, ns string, key string) ([]string, error) { diff --git a/internal/specs/specs.go b/internal/specs/specs.go index 0eeeedf3e..218888b12 100644 --- a/internal/specs/specs.go +++ b/internal/specs/specs.go @@ -9,6 +9,7 @@ import ( "os" "reflect" "strings" + "time" "github.com/pkg/errors" "github.com/replicatedhq/troubleshoot/internal/util" @@ -63,7 +64,20 @@ func SplitTroubleshootSecretLabelSelector(ctx context.Context, labelSelector lab return parsedSelectorStrings, nil } +var httpClient = &http.Client{ + Timeout: 30 * time.Second, +} + +// LoadFromCLIArgs loads troubleshoot specs from args passed to a CLI command. +// This loader function is meant for troubleshoot CLI commands only, hence not making it public. +// It will contain opinionated logic for CLI commands such as interpreting viper flags, +// supporting secret/ uri format, downloading from OCI registry and other URLs, etc. func LoadFromCLIArgs(ctx context.Context, client kubernetes.Interface, args []string, vp *viper.Viper) (*loader.TroubleshootKinds, error) { + // Let's always ensure we have a context + if ctx == nil { + ctx = context.Background() + } + rawSpecs := []string{} for _, v := range args { @@ -71,15 +85,34 @@ func LoadFromCLIArgs(ctx context.Context, client kubernetes.Interface, args []st // format secret/namespace-name/secret-name pathParts := strings.Split(v, "/") if len(pathParts) != 3 { - return nil, types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, errors.Errorf("path %s must have 3 components", v)) + return nil, types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, errors.Errorf("secret path %q must have 3 components", v)) } - spec, err := LoadFromSecret(ctx, client, pathParts[1], pathParts[2], "preflight-spec") + data, err := LoadFromSecret(ctx, client, pathParts[1], pathParts[2]) if err != nil { return nil, types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, errors.Wrap(err, "failed to get spec from secret")) } - rawSpecs = append(rawSpecs, string(spec)) + // Append all data in the secret. Some may not be specs, but that's ok. They will be ignored. + for _, spec := range data { + rawSpecs = append(rawSpecs, string(spec)) + } + } else if strings.HasPrefix(v, "configmap/") { + // format configmap/namespace-name/configmap-name + pathParts := strings.Split(v, "/") + if len(pathParts) != 3 { + return nil, errors.Errorf("configmap path %q must have 3 components", v) + } + + data, err := LoadFromConfigMap(ctx, client, pathParts[1], pathParts[2]) + if err != nil { + return nil, errors.Wrap(err, "failed to get spec from configmap") + } + + // Append all data in the configmap. Some may not be specs, but that's ok. They will be ignored. + for _, spec := range data { + rawSpecs = append(rawSpecs, spec) + } } else if _, err := os.Stat(v); err == nil { b, err := os.ReadFile(v) if err != nil { @@ -100,7 +133,7 @@ func LoadFromCLIArgs(ctx context.Context, client kubernetes.Interface, args []st } if u.Scheme == "oci" { - content, err := oci.PullPreflightFromOCI(v) + content, err := oci.PullSpecsFromOCI(ctx, v) if err != nil { if err == oci.ErrNoRelease { return nil, types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, errors.Errorf("no release found for %s.\nCheck the oci:// uri for errors or contact the application vendor for support.", v)) @@ -109,31 +142,28 @@ func LoadFromCLIArgs(ctx context.Context, client kubernetes.Interface, args []st return nil, types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, err) } - rawSpecs = append(rawSpecs, string(content)) + rawSpecs = append(rawSpecs, content...) } else { if !util.IsURL(v) { return nil, types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, fmt.Errorf("%s is not a URL and was not found (err %s)", v, err)) } - req, err := http.NewRequest("GET", v, nil) - if err != nil { - // exit code: should this be catch all or spec issues...? - return nil, types.NewExitCodeError(constants.EXIT_CODE_CATCH_ALL, err) - } - req.Header.Set("User-Agent", "Replicated_Preflight/v1beta2") - resp, err := http.DefaultClient.Do(req) + // Download preflight specs + rawSpec, err := downloadFromHttpURL(ctx, v, map[string]string{"User-Agent": "Replicated_Preflight/v1beta2"}) if err != nil { - // exit code: should this be catch all or spec issues...? - return nil, types.NewExitCodeError(constants.EXIT_CODE_CATCH_ALL, err) + return nil, err } - defer resp.Body.Close() + rawSpecs = append(rawSpecs, rawSpec) - body, err := io.ReadAll(resp.Body) + // Download support bundle specs + rawSpec, err = downloadFromHttpURL(ctx, v, map[string]string{ + "User-Agent": "Replicated_Troubleshoot/v1beta1", + "Bundle-Upload-Host": fmt.Sprintf("%s://%s", u.Scheme, u.Host), + }) if err != nil { - return nil, types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, err) + return nil, err } - - rawSpecs = append(rawSpecs, string(body)) + rawSpecs = append(rawSpecs, rawSpec) } } } @@ -157,6 +187,37 @@ func LoadFromCLIArgs(ctx context.Context, client kubernetes.Interface, args []st return kinds, nil } +func downloadFromHttpURL(ctx context.Context, url string, headers map[string]string) (string, error) { + hs := []string{} + for k, v := range headers { + hs = append(hs, fmt.Sprintf("%s: %s", k, v)) + } + + klog.V(1).Infof("Downloading troubleshoot specs: url=%s, headers=[%v]", url, strings.Join(hs, ", ")) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + // exit code: should this be catch all or spec issues...? + return "", types.NewExitCodeError(constants.EXIT_CODE_CATCH_ALL, err) + } + for k, v := range headers { + req.Header.Set(k, v) + } + + resp, err := httpClient.Do(req) + if err != nil { + // exit code: should this be catch all or spec issues...? + return "", types.NewExitCodeError(constants.EXIT_CODE_CATCH_ALL, err) + } + defer resp.Body.Close() + + klog.V(1).Infof("Response status: %s", resp.Status) + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, err) + } + return string(body), nil +} + // LoadFromCluster loads troubleshoot specs from the cluster based on the provided labels. // By default this will be troubleshoot.io/kind=support-bundle and troubleshoot.sh/kind=support-bundle // labels. We search for secrets and configmaps with the label selector and extract the raw data. diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index ad9703ca3..e69a6986f 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -70,11 +70,12 @@ const ( EXIT_CODE_WARN = 4 // Troubleshoot label constants - SupportBundleKey = "support-bundle-spec" - RedactorKey = "redactor-spec" TroubleshootIOLabelKey = "troubleshoot.io/kind" TroubleshootSHLabelKey = "troubleshoot.sh/kind" - PreflightKey = "preflight.yaml" // Shouldn't this be "preflight-spec"? + SupportBundleKey = "support-bundle-spec" + RedactorKey = "redactor-spec" + PreflightKey = "preflight.yaml" + PreflightKey2 = "preflight-spec" // Troubleshoot spec constants Troubleshootv1beta2Kind = "troubleshoot.sh/v1beta2" diff --git a/pkg/loader/loader.go b/pkg/loader/loader.go index 0244dc319..e3a95e5fa 100644 --- a/pkg/loader/loader.go +++ b/pkg/loader/loader.go @@ -42,6 +42,14 @@ type LoadOptions struct { Strict bool } +// TODO: Additional requirements needed in this package +// * Downloading specs from remote locations e.g oci, s3, http etc +// * Remote connection error handing +// * Support various auth methods +// * Retry logic and how to handle timeouts +// * Support for loading specs from paths e.g directory, file, stdin, tarballs, zips etc +// * Support for loading specs from a kubernetes cluster - concrete use case of remote location + // LoadSpecs takes sources to load specs from and returns a TroubleshootKinds object // that contains all the parsed troubleshoot specs. // @@ -59,11 +67,8 @@ func LoadSpecs(ctx context.Context, opt LoadOptions) (*TroubleshootKinds, error) l := specLoader{ strict: opt.Strict, } - return l.loadFromStrings(opt.RawSpecs...) -} -type specLoader struct { - strict bool + return l.loadFromStrings(opt.RawSpecs...) } type TroubleshootKinds struct { @@ -130,6 +135,10 @@ func NewTroubleshootKinds() *TroubleshootKinds { return &TroubleshootKinds{} } +type specLoader struct { + strict bool +} + // loadFromStrings accepts a list of strings (exploded) which should be yaml documents func (l *specLoader) loadFromStrings(rawSpecs ...string) (*TroubleshootKinds, error) { splitdocs := []string{} @@ -268,6 +277,7 @@ func isConfigMap(parsedDocHead parsedDoc) bool { // getSpecFromConfigMap extracts multiple troubleshoot specs from a secret func (l *specLoader) getSpecFromConfigMap(cm *v1.ConfigMap) ([]string, error) { + // TODO: Consider not checking for the existence of the key and just trying to decode specs := []string{} str, ok := cm.Data[constants.SupportBundleKey] @@ -282,12 +292,17 @@ func (l *specLoader) getSpecFromConfigMap(cm *v1.ConfigMap) ([]string, error) { if ok { specs = append(specs, util.SplitYAML(str)...) } + str, ok = cm.Data[constants.PreflightKey2] + if ok { + specs = append(specs, util.SplitYAML(str)...) + } return specs, nil } // getSpecFromSecret extracts multiple troubleshoot specs from a secret func (l *specLoader) getSpecFromSecret(secret *v1.Secret) ([]string, error) { + // TODO: Consider not checking for the existence of the key and just trying to decode specs := []string{} specBytes, ok := secret.Data[constants.SupportBundleKey] @@ -302,6 +317,10 @@ func (l *specLoader) getSpecFromSecret(secret *v1.Secret) ([]string, error) { if ok { specs = append(specs, util.SplitYAML(string(specBytes))...) } + specBytes, ok = secret.Data[constants.PreflightKey2] + if ok { + specs = append(specs, util.SplitYAML(string(specBytes))...) + } str, ok := secret.StringData[constants.SupportBundleKey] if ok { specs = append(specs, util.SplitYAML(str)...) @@ -314,5 +333,9 @@ func (l *specLoader) getSpecFromSecret(secret *v1.Secret) ([]string, error) { if ok { specs = append(specs, util.SplitYAML(str)...) } + str, ok = secret.StringData[constants.PreflightKey2] + if ok { + specs = append(specs, util.SplitYAML(str)...) + } return specs, nil } diff --git a/pkg/loader/loader_test.go b/pkg/loader/loader_test.go index 5ae629377..a1d12d287 100644 --- a/pkg/loader/loader_test.go +++ b/pkg/loader/loader_test.go @@ -579,3 +579,24 @@ spec: ignoreRBAC: true`) assert.Contains(t, string(y), "message: Cluster is up to date") } + +func TestLoadingEmptySpec(t *testing.T) { + s := testutils.GetTestFixture(t, "supportbundle/empty.yaml") + kinds, err := LoadSpecs(context.Background(), LoadOptions{RawSpec: s}) + require.NoError(t, err) + require.NotNil(t, kinds) + + assert.Equal(t, &TroubleshootKinds{ + SupportBundlesV1Beta2: []troubleshootv1beta2.SupportBundle{ + { + TypeMeta: metav1.TypeMeta{ + Kind: "SupportBundle", + APIVersion: "troubleshoot.sh/v1beta2", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "empty", + }, + }, + }, + }, kinds) +} diff --git a/pkg/oci/pull.go b/pkg/oci/pull.go index 6618262a7..e67f250c8 100644 --- a/pkg/oci/pull.go +++ b/pkg/oci/pull.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "net/url" "path/filepath" "strings" @@ -11,6 +12,7 @@ import ( "github.com/pkg/errors" "github.com/replicatedhq/troubleshoot/internal/util" "github.com/replicatedhq/troubleshoot/pkg/version" + "k8s.io/klog/v2" "oras.land/oras-go/pkg/auth" dockerauth "oras.land/oras-go/pkg/auth/docker" "oras.land/oras-go/pkg/content" @@ -27,14 +29,52 @@ var ( ) func PullPreflightFromOCI(uri string) ([]byte, error) { - return pullFromOCI(uri, "replicated.preflight.spec", "replicated-preflight") + return pullFromOCI(context.Background(), uri, "replicated.preflight.spec", "replicated-preflight") } func PullSupportBundleFromOCI(uri string) ([]byte, error) { - return pullFromOCI(uri, "replicated.supportbundle.spec", "replicated-supportbundle") + return pullFromOCI(context.Background(), uri, "replicated.supportbundle.spec", "replicated-supportbundle") } -func pullFromOCI(uri string, mediaType string, imageName string) ([]byte, error) { +// PullSpecsFromOCI pulls both the preflight and support bundle specs from the given URI +// +// The URI is expected to be the same as the one used to install your KOTS application +// Example oci://registry.replicated.com/app-slug/unstable will endup pulling +// preflights from "registry.replicated.com/app-slug/unstable/replicated-preflight:latest" +// and support bundles from "registry.replicated.com/app-slug/unstable/replicated-supportbundle:latest" +// Both images have their own media types created when publishing KOTS OCI image. +// NOTE: This only works with replicated registries for now and for KOTS applications only +func PullSpecsFromOCI(ctx context.Context, uri string) ([]string, error) { + // TODOs (API is opinionated, but we should be able to support these): + // - Pulling from generic OCI registries (not just replicated) + // - Pulling from registries that require authentication + // - Passing in a complete URI including tags and image name + + rawSpecs := []string{} + + // First try to pull the preflight spec + rawPreflight, err := pullFromOCI(ctx, uri, "replicated.preflight.spec", "replicated-preflight") + if err != nil { + // Ignore "not found" error and continue fetching the support bundle spec + if !errors.Is(err, ErrNoRelease) { + return nil, err + } + } else { + rawSpecs = append(rawSpecs, string(rawPreflight)) + } + + // Then try to pull the support bundle spec + rawSupportBundle, err := pullFromOCI(ctx, uri, "replicated.supportbundle.spec", "replicated-supportbundle") + // If we had found a preflight spec, do not return an error + if err != nil && len(rawSpecs) == 0 { + return nil, err + } + rawSpecs = append(rawSpecs, string(rawSupportBundle)) + + return rawSpecs, nil +} + +func pullFromOCI(ctx context.Context, uri string, mediaType string, imageName string) ([]byte, error) { // helm credentials helmCredentialsFile := filepath.Join(util.HomeDir(), HelmCredentialsFileBasename) dockerauthClient, err := dockerauth.NewClientWithDockerFallback(helmCredentialsFile) @@ -60,24 +100,14 @@ func pullFromOCI(uri string, mediaType string, imageName string) ([]byte, error) var descriptors, layers []ocispec.Descriptor registryStore := content.Registry{Resolver: resolver} - // remove the oci:// - uri = strings.TrimPrefix(uri, "oci://") - - uriParts := strings.Split(uri, ":") - uri = fmt.Sprintf("%s/%s", uriParts[0], imageName) - - if len(uriParts) > 1 { - uri = fmt.Sprintf("%s:%s", uri, uriParts[1]) - } else { - uri = fmt.Sprintf("%s:latest", uri) - } - - parsedRef, err := registry.ParseReference(uri) + parsedRef, err := parseURI(uri, imageName) if err != nil { - return nil, errors.Wrap(err, "failed to parse reference") + return nil, err } - manifest, err := oras.Copy(context.TODO(), registryStore, parsedRef.String(), memoryStore, "", + klog.V(1).Infof("Pulling spec from %q OCI uri", parsedRef) + + manifest, err := oras.Copy(ctx, registryStore, parsedRef, memoryStore, "", oras.WithPullEmptyNameAllowed(), oras.WithAllowedMediaTypes(allowedMediaTypes), oras.WithLayerDescriptors(func(l []ocispec.Descriptor) { @@ -94,7 +124,7 @@ func pullFromOCI(uri string, mediaType string, imageName string) ([]byte, error) descriptors = append(descriptors, manifest) descriptors = append(descriptors, layers...) - // expect 1 descriptor + // expect 2 descriptors if len(descriptors) != 2 { return nil, fmt.Errorf("expected 2 descriptor, got %d", len(descriptors)) } @@ -120,3 +150,34 @@ func pullFromOCI(uri string, mediaType string, imageName string) ([]byte, error) return matchingSpec, nil } + +func parseURI(in, imageName string) (string, error) { + u, err := url.Parse(in) + if err != nil { + return "", err + } + + // Always check the scheme. If more schemes need to be supported + // we need to compare u.Scheme against a list of supported schemes. + // url.Parse(raw) will not return an error if a scheme is not present. + if u.Scheme != "oci" { + return "", fmt.Errorf("%q is an invalid OCI registry scheme", u.Scheme) + } + + // remove unnecessary bits (oci://, tags) + uriParts := strings.Split(u.EscapedPath(), ":") + + tag := "latest" + if len(uriParts) > 1 { + tag = uriParts[1] + } + + uri := fmt.Sprintf("%s%s/%s:%s", u.Host, uriParts[0], imageName, tag) // :/path/:tag + + parsedRef, err := registry.ParseReference(uri) + if err != nil { + return "", errors.Wrap(err, "failed to parse OCI uri reference") + } + + return parsedRef.String(), nil +} diff --git a/pkg/oci/pull_test.go b/pkg/oci/pull_test.go new file mode 100644 index 000000000..560e93794 --- /dev/null +++ b/pkg/oci/pull_test.go @@ -0,0 +1,66 @@ +package oci + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_parseURI(t *testing.T) { + tests := []struct { + name string + uri string + imageName string + wantUri string + wantErr bool + }{ + { + name: "happy path", + uri: "oci://registry.replicated.com/app-slug/unstable", + imageName: "replicated-preflight", + wantUri: "registry.replicated.com/app-slug/unstable/replicated-preflight:latest", + }, + { + name: "no path in uri", + uri: "oci://registry.replicated.com", + imageName: "replicated-preflight", + wantUri: "registry.replicated.com/replicated-preflight:latest", + }, + { + name: "hostname with port", + uri: "oci://localhost:5000/some/path", + imageName: "replicated-preflight", + wantUri: "localhost:5000/some/path/replicated-preflight:latest", + }, + { + name: "uri with tags", + uri: "oci://localhost:5000/some/path:tag", + imageName: "replicated-preflight", + wantUri: "localhost:5000/some/path/replicated-preflight:tag", + }, + { + name: "empty uri", + wantErr: true, + }, + { + name: "invalid uri", + uri: "registry.replicated.com/app-slug/unstable", + imageName: "replicated-preflight", + wantErr: true, + }, + { + name: "invalid uri", + uri: "https://registry.replicated.com/app-slug/unstable", + imageName: "replicated-preflight", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseURI(tt.uri, tt.imageName) + require.Equalf(t, tt.wantErr, err != nil, "parseURI() error = %v, wantErr %v", err, tt.wantErr) + assert.Equalf(t, tt.wantUri, got, "parseURI() = %v, want %v", got, tt.wantUri) + }) + } +} diff --git a/pkg/preflight/flags.go b/pkg/preflight/flags.go index 1ea4e7a65..f5c85187a 100644 --- a/pkg/preflight/flags.go +++ b/pkg/preflight/flags.go @@ -16,7 +16,6 @@ const ( flagSince = "since" flagOutput = "output" flagDebug = "debug" - flagDryRun = "dry-run" ) type PreflightFlags struct { diff --git a/pkg/specs/configmaps.go b/pkg/specs/configmaps.go index 94f33c3a3..bd64e821d 100644 --- a/pkg/specs/configmaps.go +++ b/pkg/specs/configmaps.go @@ -22,7 +22,18 @@ func LoadFromConfigMap(namespace string, configMapName string, key string) ([]by if err != nil { return nil, errors.Wrap(err, "failed to convert create k8s client") } - return specs.LoadFromConfigMap(context.TODO(), client, namespace, configMapName, key) + + data, err := specs.LoadFromConfigMap(context.TODO(), client, namespace, configMapName) + if err != nil { + return nil, err + } + + spec, ok := data[key] + if !ok { + return nil, errors.Errorf("spec not found in configmap %q: key=%s", configMapName, key) + } + + return []byte(spec), nil } // LoadFromConfigMapMatchingLabel reads data from a configmap and returns the list of values extracted from the key diff --git a/pkg/specs/secrets.go b/pkg/specs/secrets.go index 4e06f5d32..1fb8f2e9c 100644 --- a/pkg/specs/secrets.go +++ b/pkg/specs/secrets.go @@ -23,7 +23,17 @@ func LoadFromSecret(namespace string, secretName string, key string) ([]byte, er return nil, errors.Wrap(err, "failed to convert create k8s client") } - return specs.LoadFromSecret(context.TODO(), client, namespace, secretName, key) + data, err := specs.LoadFromSecret(context.TODO(), client, namespace, secretName) + if err != nil { + return nil, err + } + + spec, ok := data[key] + if !ok { + return nil, errors.Errorf("spec not found in secret %q: key=%q", secretName, key) + } + + return spec, nil } // LoadFromSecretMatchingLabel reads data from a secret in the cluster using a label selector diff --git a/pkg/supportbundle/load.go b/pkg/supportbundle/load.go index 6409f4232..d804979e9 100644 --- a/pkg/supportbundle/load.go +++ b/pkg/supportbundle/load.go @@ -2,7 +2,7 @@ package supportbundle import ( "fmt" - "io/ioutil" + "io" "net/http" "net/url" "os" @@ -20,6 +20,7 @@ import ( "k8s.io/klog/v2" ) +// GetSupportBundleFromURI downloads and parses a support bundle from a URI and returns a SupportBundle object func GetSupportBundleFromURI(bundleURI string) (*troubleshootv1beta2.SupportBundle, error) { collectorContent, err := LoadSupportBundleSpec(bundleURI) if err != nil { @@ -36,6 +37,8 @@ func GetSupportBundleFromURI(bundleURI string) (*troubleshootv1beta2.SupportBund return supportbundle, nil } +// ParseSupportBundle parses a support bundle from a byte array into a SupportBundle object +// Deprecated: use loader.LoadSpecs instead func ParseSupportBundle(doc []byte, followURI bool) (*troubleshootv1beta2.SupportBundle, error) { doc, err := docrewrite.ConvertToV1Beta2(doc) if err != nil { @@ -98,10 +101,14 @@ func ParseSupportBundle(doc []byte, followURI bool) (*troubleshootv1beta2.Suppor return nil, errors.New("spec was not parseable as a troubleshoot kind") } +// ParseSupportBundle parses a support bundle from a byte array into a SupportBundle object +// Deprecated: use loader.LoadSpecs instead func ParseSupportBundleFromDoc(doc []byte) (*troubleshootv1beta2.SupportBundle, error) { return ParseSupportBundle(doc, true) } +// GetRedactorFromURI parses a redactor from a URI into a Redactor object +// Deprecated: use loader.LoadSpecs instead func GetRedactorFromURI(redactorURI string) (*troubleshootv1beta2.Redactor, error) { redactorContent, err := LoadRedactorSpec(redactorURI) if err != nil { @@ -119,6 +126,8 @@ func GetRedactorFromURI(redactorURI string) (*troubleshootv1beta2.Redactor, erro return redactor, nil } +// GetRedactorsFromURIs parses redactors from a URIs Redactor objects +// Deprecated: use loader.LoadSpecs instead func GetRedactorsFromURIs(redactorURIs []string) ([]*troubleshootv1beta2.Redact, error) { redactors := []*troubleshootv1beta2.Redact{} for _, redactor := range redactorURIs { @@ -189,7 +198,7 @@ func LoadRedactorSpec(arg string) ([]byte, error) { func loadSpec(arg string) ([]byte, error) { var err error if _, err = os.Stat(arg); err == nil { - b, err := ioutil.ReadFile(arg) + b, err := os.ReadFile(arg) if err != nil { return nil, errors.Wrap(err, "read spec file") } @@ -244,7 +253,7 @@ func loadSpecFromURL(arg string) ([]byte, error) { } defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { return nil, errors.Wrap(err, "read responce body") } @@ -253,6 +262,8 @@ func loadSpecFromURL(arg string) ([]byte, error) { } } +// ParseRedactorsFromDocs parses a slice of YAML docs and returns a slice of Redactors +// Deprecated: use loader.LoadSpecs instead func ParseRedactorsFromDocs(docs []string) ([]*troubleshootv1beta2.Redact, error) { var redactors []*troubleshootv1beta2.Redact diff --git a/pkg/supportbundle/supportbundle.go b/pkg/supportbundle/supportbundle.go index 019fa30f0..99bf2e02c 100644 --- a/pkg/supportbundle/supportbundle.go +++ b/pkg/supportbundle/supportbundle.go @@ -278,6 +278,7 @@ func ConcatSpec(target *troubleshootv1beta2.SupportBundle, source *troubleshootv newBundle.Spec.HostCollectors = append(target.Spec.HostCollectors, source.Spec.HostCollectors...) newBundle.Spec.HostAnalyzers = append(target.Spec.HostAnalyzers, source.Spec.HostAnalyzers...) newBundle.Spec.Analyzers = append(target.Spec.Analyzers, source.Spec.Analyzers...) + // TODO: What to do with the Uri field? } return newBundle } diff --git a/testdata/supportbundle/empty.yaml b/testdata/supportbundle/empty.yaml new file mode 100644 index 000000000..8f1403455 --- /dev/null +++ b/testdata/supportbundle/empty.yaml @@ -0,0 +1,5 @@ +apiVersion: troubleshoot.sh/v1beta2 +kind: SupportBundle +metadata: + name: empty +spec: {}