Files
troubleshoot/internal/specs/specs.go
Evans Mungai 0113624352 chore(support-bundle): respect using load-cluster-specs=false (#1634)
* fix: Allow using load-cluster-specs=false

Signed-off-by: Evans Mungai <evans@replicated.com>

* Some more simplification

Signed-off-by: Evans Mungai <evans@replicated.com>

* Ensure error in loading specs is printed in CLI

Signed-off-by: Evans Mungai <evans@replicated.com>

* Run linter

Signed-off-by: Evans Mungai <evans@replicated.com>

* Fix failing tests

Signed-off-by: Evans Mungai <evans@replicated.com>

* Remove unnecessary test case rename

Signed-off-by: Evans Mungai <evans@replicated.com>

* Fix error wrapping

Signed-off-by: Evans Mungai <evans@replicated.com>

* Check if load-cluster-specs was provided in cli

Signed-off-by: Evans Mungai <evans@replicated.com>

* Better wording in comments

Signed-off-by: Evans Mungai <evans@replicated.com>

---------

Signed-off-by: Evans Mungai <evans@replicated.com>
2024-10-11 13:48:32 -04:00

444 lines
15 KiB
Go

package specs
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"reflect"
"strings"
"time"
"github.com/pkg/errors"
"github.com/replicatedhq/troubleshoot/internal/util"
"github.com/replicatedhq/troubleshoot/pkg/constants"
"github.com/replicatedhq/troubleshoot/pkg/k8sutil"
"github.com/replicatedhq/troubleshoot/pkg/loader"
"github.com/replicatedhq/troubleshoot/pkg/oci"
"github.com/replicatedhq/troubleshoot/pkg/types"
"github.com/spf13/viper"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/client-go/kubernetes"
"k8s.io/klog/v2"
)
// SplitTroubleshootSecretLabelSelector splits a label selector into two selectors, if applicable:
// 1. troubleshoot.io/kind=support-bundle and non-troubleshoot (if contains) labels selector.
// 2. troubleshoot.sh/kind=support-bundle and non-troubleshoot (if contains) labels selector.
func SplitTroubleshootSecretLabelSelector(ctx context.Context, labelSelector labels.Selector) ([]string, error) {
klog.V(1).Infof("Split %q selector into troubleshoot and non-troubleshoot labels selector separately, if applicable", labelSelector.String())
selectorRequirements, selectorSelectable := labelSelector.Requirements()
if !selectorSelectable {
return nil, errors.Errorf("Selector %q is not selectable", labelSelector.String())
}
var troubleshootReqs, otherReqs []labels.Requirement
for _, req := range selectorRequirements {
if req.Key() == constants.TroubleshootIOLabelKey || req.Key() == constants.TroubleshootSHLabelKey {
troubleshootReqs = append(troubleshootReqs, req)
} else {
otherReqs = append(otherReqs, req)
}
}
parsedSelectorStrings := make([]string, 0)
// Combine each troubleshoot requirement with other requirements to form new selectors
s := labelSelector.String()
if len(troubleshootReqs) == 0 && s != "" {
return []string{s}, nil
}
for _, tReq := range troubleshootReqs {
reqs := append(otherReqs, tReq)
newSelector := labels.NewSelector().Add(reqs...)
parsedSelectorStrings = append(parsedSelectorStrings, newSelector.String())
}
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()
}
var kindsFromURL *loader.TroubleshootKinds
rawSpecs := []string{}
for _, v := range args {
if strings.HasPrefix(v, "secret/") {
// format secret/namespace-name/secret-name[/data-key]
pathParts := strings.Split(v, "/")
if len(pathParts) > 4 {
return nil, types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, errors.Errorf("secret path %s must have at most 4 components", v))
}
if len(pathParts) < 3 {
return nil, types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, errors.Errorf("secret path %s must have at least 3 components", v))
}
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"))
}
// If we have a key defined, then load specs from that key only.
if len(pathParts) == 4 {
spec, ok := data[pathParts[3]]
if ok {
rawSpecs = append(rawSpecs, string(spec))
}
} else {
// 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[/data-key]
pathParts := strings.Split(v, "/")
if len(pathParts) > 4 {
return nil, types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, errors.Errorf("configmap path %s must have at most 4 components", v))
}
if len(pathParts) < 3 {
return nil, types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, errors.Errorf("configmap path %s must have at least 3 components", v))
}
data, err := LoadFromConfigMap(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 configmap"))
}
// If we have a key defined, then load specs from that key only.
if len(pathParts) == 4 {
spec, ok := data[pathParts[3]]
if ok {
rawSpecs = append(rawSpecs, spec)
}
} else {
// 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 {
return nil, types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, err)
}
rawSpecs = append(rawSpecs, string(b))
} else if v == "-" {
b, err := io.ReadAll(os.Stdin)
if err != nil {
return nil, types.NewExitCodeError(constants.EXIT_CODE_CATCH_ALL, err)
}
rawSpecs = append(rawSpecs, string(b))
} else {
u, err := url.Parse(v)
if err != nil {
return nil, types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, err)
}
if u.Scheme == "oci" {
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))
}
return nil, types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, err)
}
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", v))
}
parsedURL, err := url.ParseRequestURI(v)
if err != nil {
return nil, types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, err)
}
var specFromURL string
if parsedURL.Host == "kots.io" {
// To download specs from kots.io, we need to set the User-Agent header
specFromURL, err = downloadFromHttpURL(ctx, v, map[string]string{
"User-Agent": "Replicated_Troubleshoot/v1beta1",
})
if err != nil {
return nil, err
}
} else {
specFromURL, err = downloadFromHttpURL(ctx, v, nil)
if err != nil {
return nil, err
}
}
// load URL spec first to remove URI key from the spec
kindsFromURL, err = loader.LoadSpecs(ctx, loader.LoadOptions{
RawSpec: specFromURL,
})
if err != nil {
return nil, err
}
// remove URI key from the spec if any
for i := range kindsFromURL.SupportBundlesV1Beta2 {
kindsFromURL.SupportBundlesV1Beta2[i].Spec.Uri = ""
}
}
}
}
kinds, err := loader.LoadSpecs(ctx, loader.LoadOptions{
RawSpecs: rawSpecs,
})
if err != nil {
return nil, err
}
if kindsFromURL != nil {
kinds.Add(kindsFromURL)
}
if vp.GetBool("load-cluster-specs") {
clusterKinds, err := LoadFromCluster(ctx, client, vp.GetStringSlice("selector"), vp.GetString("namespace"))
if err != nil {
return nil, types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, err)
}
kinds.Add(clusterKinds)
}
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.
// We then load the specs from the raw data. If the user does not have sufficient permissions
// to list & read secrets and configmaps from all namespaces, we will fallback to trying each
// namespace individually, and eventually default to the configured kubeconfig namespace.
func LoadFromCluster(ctx context.Context, client kubernetes.Interface, selectors []string, ns string) (*loader.TroubleshootKinds, error) {
klog.V(1).Infof("Load troubleshoot specs from the cluster using selectors: %v", selectors)
if reflect.DeepEqual(selectors, []string{"troubleshoot.sh/kind=support-bundle"}) {
// Its the default selector so we append troubleshoot.io/kind=support-bundle to it due to backwards compatibility
selectors = append(selectors, "troubleshoot.io/kind=support-bundle")
}
labelSelector := strings.Join(selectors, ",")
parsedSelector, err := labels.Parse(labelSelector)
if err != nil {
return nil, errors.Wrap(err, "unable to parse selector")
}
// List of namespaces we want to search for secrets and configmaps with support bundle specs
namespaces := []string{}
if ns != "" {
// Just progress with the namespace provided
namespaces = []string{ns}
} else {
// Check if I can read secrets and configmaps in all namespaces
ican, err := k8sutil.CanIListAndGetAllSecretsAndConfigMaps(ctx, client)
if err != nil {
return nil, errors.Wrap(err, "failed to check if I can read secrets and configmaps")
}
klog.V(1).Infof("Can I read any secrets and configmaps: %v", ican)
if ican {
// I can read secrets and configmaps in all namespaces
// No need to iterate over all namespaces
namespaces = []string{""}
} else {
// Get list of all namespaces and try to find specs from each namespace
nsList, err := client.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
if err != nil {
if k8serrors.IsForbidden(err) {
kubeconfig := k8sutil.GetKubeconfig()
ns, _, err := kubeconfig.Namespace()
if err != nil {
return nil, errors.Wrap(err, "failed to get namespace from kubeconfig")
}
// If we are not allowed to list namespaces, just use the default namespace
// configured in the kubeconfig
namespaces = []string{ns}
} else {
return nil, errors.Wrap(err, "failed to list namespaces")
}
}
for _, ns := range nsList.Items {
namespaces = append(namespaces, ns.Name)
}
}
}
var rawSpecs []string
parsedSelectorStrings, err := SplitTroubleshootSecretLabelSelector(ctx, parsedSelector)
if err != nil {
klog.Errorf("failed to parse troubleshoot labels selector %s", err)
}
// Iteratively search for troubleshoot specs in all namespaces using the given selectors
for _, parsedSelectorString := range parsedSelectorStrings {
klog.V(1).Infof("Search specs from [%q] namespace using %q selector", strings.Join(namespaces, ", "), parsedSelectorString)
for _, ns := range namespaces {
for _, key := range []string{constants.SupportBundleKey, constants.PreflightKey, constants.RedactorKey} {
specs, err := LoadFromSecretMatchingLabel(ctx, client, parsedSelectorString, ns, key)
if err != nil {
if !k8serrors.IsForbidden(err) {
klog.Errorf("failed to load support bundle spec from secrets: %s", err)
} else {
klog.Warningf("Reading secrets from %q namespace forbidden", ns)
}
}
rawSpecs = append(rawSpecs, specs...)
specs, err = LoadFromConfigMapMatchingLabel(ctx, client, parsedSelectorString, ns, key)
if err != nil {
if !k8serrors.IsForbidden(err) {
klog.Errorf("failed to load support bundle spec from configmap: %s", err)
} else {
klog.Warningf("Reading configmaps from %q namespace forbidden", ns)
}
}
rawSpecs = append(rawSpecs, specs...)
}
}
}
// Load troubleshoot specs from the raw specs
return loader.LoadSpecs(ctx, loader.LoadOptions{
RawSpecs: rawSpecs,
})
}
// LoadAdditionalSpecFromURIs loads additional specs from the URIs provided in the kinds.
// This function will modify kinds in place.
func LoadAdditionalSpecFromURIs(ctx context.Context, kinds *loader.TroubleshootKinds) {
obj := reflect.ValueOf(*kinds)
// iterate over all fields of the TroubleshootKinds
// e.g. SupportBundlesV1Beta2, PreflightsV1Beta2, etc.
for i := 0; i < obj.NumField(); i++ {
field := obj.Field(i)
if field.Kind() != reflect.Slice {
continue
}
// look at each spec in the slice
// e.g. each spec in []PreflightsV1Beta2
for count := 0; count < field.Len(); count++ {
currentSpec := field.Index(count)
specName := currentSpec.Type().Name()
// check if .Spec.Uri exists
specField := currentSpec.FieldByName("Spec")
if !specField.IsValid() {
continue
}
uriField := specField.FieldByName("Uri")
if uriField.Kind() != reflect.String {
continue
}
// download spec from URI
uri := uriField.String()
if uri == "" {
continue
}
rawSpec, err := downloadFromHttpURL(ctx, uri, nil)
if err != nil {
klog.Warningf("failed to download spec from URI %q: %v", uri, err)
continue
}
// load spec from raw spec
uriKinds, err := loader.LoadSpecs(ctx, loader.LoadOptions{RawSpec: string(rawSpec)})
if err != nil {
klog.Warningf("failed to load spec from URI %q: %v", uri, err)
continue
}
// replace original spec with the loaded spec from URI
newSpec := getFirstSpecOf(uriKinds, specName)
if !newSpec.IsValid() {
klog.Warningf("failed to read spec of type %s in URI %s", specName, uri)
continue
}
currentSpec.Set(newSpec)
}
}
}
// dynamically get spec from kinds of given name
// return first element of the spec slice
func getFirstSpecOf(kinds *loader.TroubleshootKinds, name string) reflect.Value {
obj := reflect.ValueOf(*kinds)
for i := 0; i < obj.NumField(); i++ {
field := obj.Field(i)
if field.Kind() != reflect.Slice {
continue
}
if field.Len() > 0 {
if field.Index(0).Type().Name() == name {
// return first element
return field.Index(0)
}
}
}
return reflect.Value{}
}