From f01f0f6fbd21c8828e62cf934b4c17967936bc72 Mon Sep 17 00:00:00 2001 From: Kautilya Tripathi Date: Thu, 12 Sep 2024 11:02:09 +0530 Subject: [PATCH] backend: refactor listing valid contexts and error This removes the dependacy of manually parsing of k8s resources. It was a problem as we had to be in sync with k8s API changes, which is not a good way. This change removes that and it splits kubeconfig into seperate contexts and validates them. Fixes: #2347 Co-Authored-By: Santhosh Nagaraj Signed-off-by: Kautilya Tripathi --- backend/cmd/cluster.go | 1 + backend/cmd/headlamp.go | 224 +++-- backend/cmd/stateless.go | 14 +- backend/pkg/kubeconfig/kubeconfig.go | 881 ++++++++---------- backend/pkg/kubeconfig/kubeconfig_test.go | 470 +++++++--- .../test_data/kubeconfig_partialcontextvalid | 16 +- backend/pkg/portforward/handler_test.go | 7 +- 7 files changed, 902 insertions(+), 711 deletions(-) diff --git a/backend/cmd/cluster.go b/backend/cmd/cluster.go index 92fec12c9c..37625163fe 100644 --- a/backend/cmd/cluster.go +++ b/backend/cmd/cluster.go @@ -9,6 +9,7 @@ type Cluster struct { Server string `json:"server,omitempty"` AuthType string `json:"auth_type"` Metadata map[string]interface{} `json:"meta_data"` + Error string `json:"error,omitempty"` } type ClusterReq struct { diff --git a/backend/cmd/headlamp.go b/backend/cmd/headlamp.go index e7ae95e8a8..13f4a56f7e 100644 --- a/backend/cmd/headlamp.go +++ b/backend/cmd/headlamp.go @@ -256,8 +256,8 @@ func defaultKubeConfigPersistenceFile() (string, error) { } // addPluginRoutes adds plugin routes to a router. -// It serves plugin list base paths as json at “/plugins”. -// It serves plugin static files at “/plugins/” and “/static-plugins/”. +// It serves plugin list base paths as json at "plugins". +// It serves plugin static files at "plugins/" and "static-plugins/". // It disables caching and reloads plugin list base paths if not in-cluster. func addPluginRoutes(config *HeadlampConfig, r *mux.Router) { // Delete plugin route @@ -1013,6 +1013,14 @@ func handleClusterAPI(c *HeadlampConfig, router *mux.Router) { return } + if kContext.Error != "" { + logger.Log(logger.LevelError, map[string]string{"key": contextKey}, + errors.New(kContext.Error), "context has error") + http.Error(w, kContext.Error, http.StatusBadRequest) + + return + } + clusterURL, err := url.Parse(kContext.Cluster.Server) if err != nil { logger.Log(logger.LevelError, map[string]string{"ClusterURL": kContext.Cluster.Server}, @@ -1062,6 +1070,15 @@ func (c *HeadlampConfig) getClusters() []Cluster { for _, context := range contexts { context := context + if context.Error != "" { + clusters = append(clusters, Cluster{ + Name: context.Name, + Error: context.Error, + }) + + continue + } + // Dynamic clusters should not be visible to other users. if context.Internal { continue @@ -1138,43 +1155,33 @@ func parseCustomNameClusters(contexts []kubeconfig.Context) ([]Cluster, []error) // parseClusterFromKubeConfig parses the kubeconfig and returns a list of contexts and errors. func parseClusterFromKubeConfig(kubeConfigs []string) ([]Cluster, []error) { - clusters := []Cluster{} + var clusters []Cluster var setupErrors []error for _, kubeConfig := range kubeConfigs { - var contexts []kubeconfig.Context - - kubeConfigByte, err := base64.StdEncoding.DecodeString(kubeConfig) + contexts, contextLoadErrors, err := kubeconfig.LoadContextsFromBase64String(kubeConfig, kubeconfig.DynamicCluster) if err != nil { - logger.Log(logger.LevelError, nil, err, "decoding kubeconfig") - setupErrors = append(setupErrors, err) - continue } - config, err := clientcmd.Load(kubeConfigByte) - if err != nil { - logger.Log(logger.LevelError, nil, err, "loading kubeconfig") - - setupErrors = append(setupErrors, err) - - continue + if len(contextLoadErrors) > 0 { + for _, contextError := range contextLoadErrors { + setupErrors = append(setupErrors, contextError.Error) + } } - contexts, errs := kubeconfig.LoadContextsFromAPIConfig(config, true) - if len(errs) > 0 { - setupErrors = append(setupErrors, errs...) - continue + parsedClusters, parseErrs := parseCustomNameClusters(contexts) + if len(parseErrs) > 0 { + setupErrors = append(setupErrors, parseErrs...) } - clusters, setupErrors = parseCustomNameClusters(contexts) + clusters = append(clusters, parsedClusters...) } if len(setupErrors) > 0 { logger.Log(logger.LevelError, nil, setupErrors, "setting up contexts from kubeconfig") - return nil, setupErrors } @@ -1191,117 +1198,150 @@ func (c *HeadlampConfig) getConfig(w http.ResponseWriter, r *http.Request) { } } -//nolint:funlen,nestif +// addCluster adds cluster to store and updates the kubeconfig file. func (c *HeadlampConfig) addCluster(w http.ResponseWriter, r *http.Request) { if err := checkHeadlampBackendToken(w, r); err != nil { logger.Log(logger.LevelError, nil, err, "invalid token") + return + } + clusterReq, err := decodeClusterRequest(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } - clusterReq := ClusterReq{} - if err := json.NewDecoder(r.Body).Decode(&clusterReq); err != nil { - logger.Log(logger.LevelError, nil, err, "decoding cluster info") - http.Error(w, "decoding cluster info", http.StatusBadRequest) + contexts, setupErrors := c.processClusterRequest(clusterReq) + + if len(contexts) == 0 { + logger.Log(logger.LevelError, nil, errors.New("no contexts found in kubeconfig"), "getting contexts from kubeconfig") + http.Error(w, "getting contexts from kubeconfig", http.StatusBadRequest) return } - if (clusterReq.KubeConfig == nil) && (clusterReq.Name == nil || clusterReq.Server == nil) { - logger.Log(logger.LevelError, nil, errors.New("creating cluster with invalid info"), - "please provide a 'name' and 'server' fields at least") - http.Error(w, "creating cluster with invalid info; please provide a 'name' and 'server' fields at least.", - http.StatusBadRequest) + setupErrors = c.addContextsToStore(contexts, setupErrors) + + if len(setupErrors) > 0 { + logger.Log(logger.LevelError, nil, setupErrors, "setting up contexts from kubeconfig") + http.Error(w, "setting up contexts from kubeconfig", http.StatusBadRequest) return } - var contexts []kubeconfig.Context + w.WriteHeader(http.StatusCreated) + c.getConfig(w, r) +} - var setupErrors []error +// decodeClusterRequest decodes the cluster request from the request body. +func decodeClusterRequest(r *http.Request) (ClusterReq, error) { + var clusterReq ClusterReq + if err := json.NewDecoder(r.Body).Decode(&clusterReq); err != nil { + logger.Log(logger.LevelError, nil, err, "decoding cluster info") + return ClusterReq{}, fmt.Errorf("decoding cluster info: %w", err) + } - if clusterReq.KubeConfig != nil { - kubeConfigByte, err := base64.StdEncoding.DecodeString(*clusterReq.KubeConfig) - if err != nil { - logger.Log(logger.LevelError, nil, err, "decoding kubeconfig") - http.Error(w, "decoding kubeconfig", http.StatusBadRequest) + if (clusterReq.KubeConfig == nil) && (clusterReq.Name == nil || clusterReq.Server == nil) { + return ClusterReq{}, errors.New("please provide a 'name' and 'server' fields at least") + } - return - } + return clusterReq, nil +} - config, err := clientcmd.Load(kubeConfigByte) - if err != nil { - logger.Log(logger.LevelError, nil, err, "loading kubeconfig") - http.Error(w, "loading kubeconfig", http.StatusBadRequest) +// processClusterRequest processes the cluster request. +func (c *HeadlampConfig) processClusterRequest(clusterReq ClusterReq) ([]kubeconfig.Context, []error) { + if clusterReq.KubeConfig != nil { + return c.processKubeConfig(clusterReq) + } - return - } + return c.processManualConfig(clusterReq) +} - kubeConfigPersistenceDir, err := defaultKubeConfigPersistenceDir() - if err != nil { - logger.Log(logger.LevelError, nil, err, "getting default kubeconfig persistence dir") - http.Error(w, "getting default kubeconfig persistence dir", http.StatusInternalServerError) +// processKubeConfig processes the kubeconfig request. +func (c *HeadlampConfig) processKubeConfig(clusterReq ClusterReq) ([]kubeconfig.Context, []error) { + contexts, contextLoadErrors, err := kubeconfig.LoadContextsFromBase64String( + *clusterReq.KubeConfig, + kubeconfig.DynamicCluster, + ) + setupErrors := c.handleLoadErrors(err, contextLoadErrors) - return + if len(contextLoadErrors) == 0 { + if err := c.writeKubeConfig(*clusterReq.KubeConfig); err != nil { + setupErrors = append(setupErrors, err) } + } - err = kubeconfig.WriteToFile(*config, kubeConfigPersistenceDir) - if err != nil { - logger.Log(logger.LevelError, nil, err, "writing kubeconfig") - http.Error(w, "writing kubeconfig", http.StatusBadRequest) - - return - } + return contexts, setupErrors +} - contexts, setupErrors = kubeconfig.LoadContextsFromAPIConfig(config, false) - } else { - conf := &api.Config{ - Clusters: map[string]*api.Cluster{ - *clusterReq.Name: { - Server: *clusterReq.Server, - InsecureSkipTLSVerify: clusterReq.InsecureSkipTLSVerify, - CertificateAuthorityData: clusterReq.CertificateAuthorityData, - }, +// processManualConfig processes the manual config request. +func (c *HeadlampConfig) processManualConfig(clusterReq ClusterReq) ([]kubeconfig.Context, []error) { + conf := &api.Config{ + Clusters: map[string]*api.Cluster{ + *clusterReq.Name: { + Server: *clusterReq.Server, + InsecureSkipTLSVerify: clusterReq.InsecureSkipTLSVerify, + CertificateAuthorityData: clusterReq.CertificateAuthorityData, }, - Contexts: map[string]*api.Context{ - *clusterReq.Name: { - Cluster: *clusterReq.Name, - }, + }, + Contexts: map[string]*api.Context{ + *clusterReq.Name: { + Cluster: *clusterReq.Name, }, - } + }, + } + + return kubeconfig.LoadContextsFromAPIConfig(conf, false) +} - contexts, setupErrors = kubeconfig.LoadContextsFromAPIConfig(conf, false) +// handleLoadErrors handles the load errors. +func (c *HeadlampConfig) handleLoadErrors(err error, contextLoadErrors []kubeconfig.ContextLoadError) []error { + var setupErrors []error //nolint:prealloc + + if err != nil { + setupErrors = append(setupErrors, err) } - if len(contexts) == 0 { - logger.Log(logger.LevelError, nil, errors.New("no contexts found in kubeconfig"), - "getting contexts from kubeconfig") - http.Error(w, "getting contexts from kubeconfig", http.StatusBadRequest) + for _, contextError := range contextLoadErrors { + setupErrors = append(setupErrors, contextError.Error) + } - return + return setupErrors +} + +// writeKubeConfig writes the kubeconfig to the kubeconfig file. +func (c *HeadlampConfig) writeKubeConfig(kubeConfigBase64 string) error { + kubeConfigByte, err := base64.StdEncoding.DecodeString(kubeConfigBase64) + if err != nil { + return fmt.Errorf("decoding kubeconfig: %w", err) } - for _, context := range contexts { - context := context - context.Source = kubeconfig.DynamicCluster + config, err := clientcmd.Load(kubeConfigByte) + if err != nil { + return fmt.Errorf("loading kubeconfig: %w", err) + } - err := c.kubeConfigStore.AddContext(&context) - if err != nil { - setupErrors = append(setupErrors, err) - } + kubeConfigPersistenceDir, err := defaultKubeConfigPersistenceDir() + if err != nil { + return fmt.Errorf("getting default kubeconfig persistence dir: %w", err) } - if len(setupErrors) > 0 { - logger.Log(logger.LevelError, nil, setupErrors, "setting up contexts from kubeconfig") - http.Error(w, "setting up contexts from kubeconfig", http.StatusBadRequest) + return kubeconfig.WriteToFile(*config, kubeConfigPersistenceDir) +} - return +// addContextsToStore adds the contexts to the store. +func (c *HeadlampConfig) addContextsToStore(contexts []kubeconfig.Context, setupErrors []error) []error { + for i := range contexts { + contexts[i].Source = kubeconfig.DynamicCluster + if err := c.kubeConfigStore.AddContext(&contexts[i]); err != nil { + setupErrors = append(setupErrors, err) + } } - w.WriteHeader(http.StatusCreated) - c.getConfig(w, r) + return setupErrors } +// deleteCluster deletes the cluster from the store and updates the kubeconfig file. func (c *HeadlampConfig) deleteCluster(w http.ResponseWriter, r *http.Request) { if err := checkHeadlampBackendToken(w, r); err != nil { logger.Log(logger.LevelError, nil, err, "invalid token") diff --git a/backend/cmd/stateless.go b/backend/cmd/stateless.go index 3b69235af9..312faac2ad 100644 --- a/backend/cmd/stateless.go +++ b/backend/cmd/stateless.go @@ -66,6 +66,8 @@ func (c *HeadlampConfig) setKeyInCache(key string, context kubeconfig.Context) e // Handles stateless cluster requests if kubeconfig is set and dynamic clusters are enabled. // It returns context key which is used to store the context in the cache. +// +//nolint:funlen func (c *HeadlampConfig) handleStatelessReq(r *http.Request, kubeConfig string) (string, error) { var key string @@ -76,11 +78,17 @@ func (c *HeadlampConfig) handleStatelessReq(r *http.Request, kubeConfig string) // unique key for the context key = clusterName + userID - contexts, errs := kubeconfig.LoadContextsFromBase64String(kubeConfig, kubeconfig.DynamicCluster) - if len(errs) > 0 { + contexts, contextLoadErrors, err := kubeconfig.LoadContextsFromBase64String(kubeConfig, kubeconfig.DynamicCluster) + if len(contextLoadErrors) > 0 { // Log all errors - for _, err := range errs { + for _, contextError := range contextLoadErrors { + logger.Log(logger.LevelError, nil, contextError.Error, "loading contexts from kubeconfig") + } + + if err != nil { logger.Log(logger.LevelError, nil, err, "loading contexts from kubeconfig") + + return "", err } // If no contexts were loaded, return an error diff --git a/backend/pkg/kubeconfig/kubeconfig.go b/backend/pkg/kubeconfig/kubeconfig.go index 2cce311da4..6419119bfe 100644 --- a/backend/pkg/kubeconfig/kubeconfig.go +++ b/backend/pkg/kubeconfig/kubeconfig.go @@ -14,12 +14,12 @@ import ( "gopkg.in/yaml.v2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" k8sruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/clientcmd/api" "github.com/headlamp-k8s/headlamp/backend/pkg/logger" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" - "k8s.io/client-go/tools/clientcmd/api" ) // TODO: Use a different way to avoid name clashes with other clusters. @@ -41,6 +41,7 @@ type Context struct { OidcConf *OidcConfig `json:"oidcConfig"` proxy *httputil.ReverseProxy `json:"-"` Internal bool `json:"internal"` + Error string `json:"error"` } type OidcConfig struct { @@ -120,6 +121,26 @@ func (e DataError) Error() string { return fmt.Sprintf("Error in field '%s': %s", e.Field, e.Reason) } +// Base64Error is an error that occurs when decoding base64 data. +type Base64Error struct { + ContextName string + ClusterName string + UserName string + Errors []error +} + +// Error returns a string representation of the error. +func (e Base64Error) Error() string { + var messages []string //nolint:prealloc + + for _, err := range e.Errors { + messages = append(messages, err.Error()) + } + + return fmt.Sprintf("Base64 decoding errors in context '%s', cluster '%s', user '%s':\n%s", + e.ContextName, e.ClusterName, e.UserName, strings.Join(messages, "\n")) +} + // ClientConfig returns a clientcmd.ClientConfig for the context. func (c *Context) ClientConfig() clientcmd.ClientConfig { // If the context is empty, return nil. @@ -258,357 +279,492 @@ func (c *Context) AuthType() string { return "" } +// ContextLoadError represents an error associated with a specific context. +type ContextLoadError struct { + ContextName string + Error error +} + // LoadContextsFromFile loads contexts from a kubeconfig file. // It reads the kubeconfig file from the given path and loads the contexts from the file. // It returns an error if the file cannot be read. -// It will return valid contexts and errors if there are any errors in the file. -func LoadContextsFromFile(kubeConfigPath string, source int) ([]Context, []error) { +// It will return valid contexts, ContextLoadError and errors if there are any errors in the file. +func LoadContextsFromFile(kubeConfigPath string, source int) ([]Context, []ContextLoadError, error) { data, err := os.ReadFile(kubeConfigPath) if err != nil { - return nil, []error{fmt.Errorf("error reading kubeconfig file: %v", err)} + return nil, nil, fmt.Errorf("error reading kubeconfig file: %v", err) } - return loadContextsFromData(data, source) + skipProxySetup := source != KubeConfig + + return loadContextsFromData(data, source, skipProxySetup) } // LoadContextsFromBase64String loads contexts from a base64 encoded kubeconfig string. // It decodes the base64 encoded kubeconfig string and loads the contexts from the decoded string. // It returns an error if the base64 decoding fails. -// It will return valid contexts and errors if there are any errors in the file. -func LoadContextsFromBase64String(kubeConfig string, source int) ([]Context, []error) { +// It will return valid contexts, ContextLoadError and errors if there are any errors in the file. +func LoadContextsFromBase64String(kubeConfig string, source int) ([]Context, []ContextLoadError, error) { kubeConfigByte, err := base64.StdEncoding.DecodeString(kubeConfig) if err != nil { - return nil, []error{fmt.Errorf("error decoding base64 kubeconfig: %v", err)} + return nil, nil, fmt.Errorf("error decoding base64 kubeconfig: %v", err) + } + + skipProxySetup := source != KubeConfig + + return loadContextsFromData(kubeConfigByte, source, skipProxySetup) +} + +// LoadContextsFromMultipleFiles loads contexts from the given kubeconfig files. +func LoadContextsFromMultipleFiles(kubeConfigs string, source int) ([]Context, []ContextLoadError, error) { + var contexts []Context + + var contextErrors []ContextLoadError + + kubeConfigPaths := splitKubeConfigPath(kubeConfigs) + for _, kubeConfigPath := range kubeConfigPaths { + kubeConfigContexts, errs, err := LoadContextsFromFile(kubeConfigPath, source) + if err != nil { + return nil, nil, err + } + + contexts = append(contexts, kubeConfigContexts...) + contextErrors = append(contextErrors, errs...) } - return loadContextsFromData(kubeConfigByte, source) + return contexts, contextErrors, nil } -// loadContextsFromData is a helper function that loads contexts from a byte slice. -// It parses the byte slice as a YAML file and validates the parsed data. -// It then creates contexts from the parsed data and returns them. -// It returns any errors that occurred during parsing or validation. -func loadContextsFromData(data []byte, source int) ([]Context, []error) { +// loadContextsFromData loads contexts from a kubeconfig data. +// It unmarshals the kubeconfig data, extracts the contexts, and processes each context. +// It returns all contexts, contextLoadErrors and any errors that occurred during the process. +// It does not matter if a context has errors, it will be returned. +func loadContextsFromData(data []byte, source int, skipProxySetup bool) ([]Context, []ContextLoadError, error) { var contexts []Context //nolint:prealloc - var errs []error + var contextErrors []ContextLoadError - // Parse and validate the config. - rawConfig, _, err := parseAndValidateConfig(data) + // Unmarshal the kubeconfig data + kubeconfig, err := UnmarshalKubeconfig(data) if err != nil { - // Log the error but continue processing - errs = append(errs, fmt.Errorf("error parsing config: %v", err)) + return nil, nil, err } - // Extract contexts, clusters, and users from the raw config. - rawContexts, _ := rawConfig["contexts"].([]interface{}) - rawClusters, _ := rawConfig["clusters"].([]interface{}) - rawUsers, _ := rawConfig["users"].([]interface{}) + // Get the contexts from the kubeconfig + rawContexts, err := GetContextsFromKubeconfig(kubeconfig) + if err != nil { + return nil, nil, err + } - // Create contexts from the parsed data. + // Process each context for _, rawContext := range rawContexts { - context, err := createContext(rawContext, rawClusters, rawUsers, source) + context, err := ProcessContext(rawContext, kubeconfig, source, skipProxySetup) if err != nil { - errs = append(errs, err) - continue + contextErrors = append(contextErrors, ContextLoadError{ + ContextName: context.Name, + Error: err, + }) } contexts = append(contexts, context) } - // Return the contexts and any errors that occurred. - return contexts, errs + return contexts, contextErrors, nil } -// parseAndValidateConfig parses and validates a kubeconfig file. -// It returns the raw config, the parsed config, and any errors that occurred. -// It returns an error if the file cannot be parsed. -// It returns a valid config if the file is parsed and validated. -// It returns any errors that occurred during parsing or validation. -func parseAndValidateConfig(data []byte) (map[string]interface{}, *api.Config, error) { - var rawConfig map[string]interface{} +// UnmarshalKubeconfig unmarshals the kubeconfig data. +func UnmarshalKubeconfig(data []byte) (map[string]interface{}, error) { + var kubeconfig map[string]interface{} - err := yaml.Unmarshal(data, &rawConfig) + err := yaml.Unmarshal(data, &kubeconfig) if err != nil { - return nil, nil, err + return nil, DataError{Field: "kubeconfig", Reason: fmt.Sprintf("error unmarshaling YAML: %v", err)} } - config, err := clientcmd.Load(data) - if err != nil { - return rawConfig, nil, err - } + return kubeconfig, nil +} - if err := clientcmd.Validate(*config); err != nil { - return rawConfig, config, err +// GetContextsFromKubeconfig gets the contexts from the kubeconfig. +func GetContextsFromKubeconfig(kubeconfig map[string]interface{}) ([]interface{}, error) { + rawContexts, ok := kubeconfig["contexts"].([]interface{}) + if !ok { + return nil, DataError{Field: "contexts", Reason: "invalid or missing contexts in kubeconfig"} } - return rawConfig, config, nil + return rawContexts, nil } -// createContext creates a context from the given raw context, clusters, and users. -// It returns the created context and any errors that occurred. -// It returns an error if the context cannot be created. -// It returns a valid context if the context is created. -// It returns any errors that occurred during context creation. -func createContext(rawContext interface{}, rawClusters, rawUsers []interface{}, source int) (Context, error) { - contextMap, ok := rawContext.(map[interface{}]interface{}) - if !ok { - return Context{}, ContextError{ContextName: "unknown", Reason: "invalid context format"} - } +// ProcessContext processes a context from the kubeconfig. +// It returns errors for invalid contexts, but will return all contexts. +// rawContext can be a single context or a list of contexts. +// kubeconfig is the kubeconfig data. +// source is the source of the kubeconfig, i.e where the kubeconfig came from. +// It can be KubeConfig, DynamicCluster, or InCluster. +// skipProxySetup is a flag to skip proxy setup. +func ProcessContext( + rawContext interface{}, + kubeconfig map[string]interface{}, + source int, + skipProxySetup bool, +) (Context, error) { + var errs []error - name, _ := contextMap["name"].(string) - contextData, _ := contextMap["context"].(map[interface{}]interface{}) - clusterName, _ := contextData["cluster"].(string) - userName, _ := contextData["user"].(string) + var context Context + // Extract context information + contextMap, contextName, err := extractContextInfo(rawContext) + if err != nil { + errs = append(errs, err) + } - cluster, err := findCluster(rawClusters, clusterName) + // Extract cluster and user names + clusterName, userName, err := extractClusterAndUserNames(contextMap, contextName) if err != nil { - return Context{}, ContextError{ContextName: name, Reason: err.Error()} + errs = append(errs, err) } - authInfo, err := findAuthInfo(rawUsers, userName) + // Get the cluster and user details + cluster, user, err := getClusterAndUser(kubeconfig, clusterName, userName) if err != nil { - return Context{}, ContextError{ContextName: name, Reason: err.Error()} + errs = append(errs, err) } - friendlyName := makeDNSFriendly(name) - kubeContext := createKubeContext(contextData) + // Create and validate the config + singleConfig, err := createAndValidateConfig(contextName, contextMap, cluster, user, kubeconfig) + if err != nil { + errs = append(errs, err) + context = Context{ + Name: contextName, + Error: err.Error(), + } - newContext := Context{ - Name: friendlyName, - KubeContext: kubeContext, - Cluster: cluster, - AuthInfo: authInfo, - Source: source, + return context, errors.Join(errs...) } - err = newContext.SetupProxy() + // Convert the config to a context + context, err = convertToContext(contextName, singleConfig, source, skipProxySetup) if err != nil { - return Context{}, ContextError{ContextName: name, Reason: fmt.Sprintf("couldn't setup proxy: %v", err)} + errs = append(errs, err) } - return newContext, nil + return context, errors.Join(errs...) } -// findCluster finds a cluster in the given raw clusters. -// It returns the found cluster and any errors that occurred. -// It returns an error if the cluster cannot be found. -// It returns a valid cluster if the cluster is found. -// It returns any errors that occurred during cluster search. -func findCluster(rawClusters []interface{}, clusterName string) (*api.Cluster, error) { - for _, rawCluster := range rawClusters { - clusterMap, ok := rawCluster.(map[interface{}]interface{}) - if !ok || clusterMap["name"] != clusterName { - continue - } - - cluster := &api.Cluster{} - clusterData, _ := clusterMap["cluster"].(map[interface{}]interface{}) - - for key, value := range clusterData { - strKey, ok := key.(string) - if !ok { - return nil, ClusterError{ClusterName: clusterName, Reason: fmt.Sprintf("invalid key type for field '%v'", key)} - } +// extractContextInfo extracts the context information from the raw context. +func extractContextInfo(rawContext interface{}) (map[interface{}]interface{}, string, error) { + contextMap, ok := rawContext.(map[interface{}]interface{}) + if !ok { + return nil, "", DataError{Field: "context", Reason: fmt.Sprintf("invalid context format: %v", rawContext)} + } - switch strKey { - case "certificate-authority-data": - if data, ok := value.(string); ok { - decoded, err := base64.StdEncoding.DecodeString(data) - if err != nil { - return nil, DataError{Field: "certificate-authority-data", Reason: "invalid base64 encoding"} - } - - cluster.CertificateAuthorityData = decoded - } else { - return nil, DataError{Field: "certificate-authority-data", Reason: "expected string, got different type"} - } - case "extensions": - cluster.Extensions = createExtensions(value) - default: - err := SetClusterField(cluster, strKey, value) - if err != nil { - return nil, ClusterError{ClusterName: clusterName, Reason: err.Error()} - } - } + contextName, ok := contextMap["name"].(string) + if !ok { + return nil, "", DataError{ + Field: "context.name", + Reason: fmt.Sprintf("missing or invalid context name: %v", contextMap["name"]), } - - return cluster, nil } - return nil, ClusterError{ClusterName: clusterName, Reason: "cluster not found"} + return contextMap, contextName, nil } -// findAuthInfo finds an auth info in the given raw users. -// It returns the found auth info and any errors that occurred. -// It returns an error if the auth info cannot be found. -// It returns a valid auth info if the auth info is found. -// It returns any errors that occurred during auth info search. -func findAuthInfo(rawUsers []interface{}, userName string) (*api.AuthInfo, error) { - for _, rawUser := range rawUsers { - userMap, ok := rawUser.(map[interface{}]interface{}) - if !ok || userMap["name"] != userName { - continue +// extractClusterAndUserNames extracts the cluster and user names from the context. +func extractClusterAndUserNames(contextMap map[interface{}]interface{}, contextName string) (string, string, error) { + contextData, ok := contextMap["context"].(map[interface{}]interface{}) + if !ok { + return "", "", ContextError{ + ContextName: contextName, + Reason: fmt.Sprintf("invalid context data: %v", contextMap["context"]), } + } - authInfo := &api.AuthInfo{} - userData, _ := userMap["user"].(map[interface{}]interface{}) + clusterName := contextData["cluster"].(string) - for key, value := range userData { - strKey, ok := key.(string) - if !ok { - return nil, UserError{UserName: userName, Reason: fmt.Sprintf("invalid key type for field '%v'", key)} - } + userName := contextData["user"].(string) - err := SetAuthInfoField(authInfo, strKey, value) - if err != nil { - return nil, UserError{UserName: userName, Reason: err.Error()} - } - } + return clusterName, userName, nil +} + +// getClusterAndUser gets the cluster and user details from the kubeconfig. +func getClusterAndUser( + kubeconfig map[string]interface{}, + clusterName, + userName string, +) (map[interface{}]interface{}, map[interface{}]interface{}, error) { + var errs []error + + cluster, err := getCluster(kubeconfig, clusterName) + if err != nil { + errs = append(errs, err) + } - return authInfo, nil + user, err := getUser(kubeconfig, userName) + if err != nil { + errs = append(errs, err) } - return nil, UserError{UserName: userName, Reason: "user not found"} + return cluster, user, errors.Join(errs...) } -// createAuthProvider creates an auth provider from the given value. -// It returns the created auth provider and any errors that occurred. -// It returns an error if the auth provider cannot be created. -// It returns a valid auth provider if the auth provider is created. -// It returns any errors that occurred during auth provider creation. -func createAuthProvider(value interface{}) *api.AuthProviderConfig { - provider, ok := value.(map[interface{}]interface{}) - if !ok { - return nil +// createAndValidateConfig creates and validates the config. +func createAndValidateConfig( + contextName string, + contextMap, + cluster, + user map[interface{}]interface{}, + kubeconfig map[string]interface{}, +) (*api.Config, error) { + singleConfig := createKubeConfig(contextName, + toStringKeyMap(contextMap), + toStringKeyMap(cluster), + toStringKeyMap(user)) + + yamlData, err := yaml.Marshal(singleConfig) + if err != nil { + return nil, ContextError{ + ContextName: contextName, + Reason: fmt.Sprintf("error marshaling to YAML: %v", err), + } } - authProvider := &api.AuthProviderConfig{} - authProvider.Name, _ = provider["name"].(string) + clientConfig, err := clientcmd.Load(yamlData) + if err != nil { + clusterName := getNameOrUnknown(cluster, "name") + userName := getNameOrUnknown(user, "name") + + return nil, HandleConfigLoadError(err, contextName, clusterName, userName, kubeconfig) + } - if config, ok := provider["config"].(map[interface{}]interface{}); ok { - authProvider.Config = make(map[string]string) + return clientConfig, nil +} - for k, v := range config { - if key, ok := k.(string); ok { - if val, ok := v.(string); ok { - authProvider.Config[key] = val - } - } +// getNameOrUnknown returns the name of the cluster or user if it exists, otherwise it returns "unknown". +func getNameOrUnknown(data map[interface{}]interface{}, key string) string { + if nameInterface, ok := data[key]; ok { + if nameString, ok := nameInterface.(string); ok { + return nameString } } - return authProvider + return "unknown" +} + +// HandleConfigLoadError handles the error when loading the config. +func HandleConfigLoadError( + err error, + contextName, + clusterName, + userName string, + kubeconfig map[string]interface{}, +) error { + switch { + case strings.Contains(err.Error(), "illegal base64"): + return checkBase64Errors(kubeconfig, contextName, clusterName, userName) + case strings.Contains(err.Error(), "no server found"): + return ClusterError{ + ClusterName: clusterName, + Reason: "No server URL specified. Please check the cluster configuration.", + } + case strings.Contains(err.Error(), "unable to read client-cert"): + return UserError{ + UserName: userName, + Reason: "Unable to read client certificate. Please ensure the certificate file exists and is readable.", + } + case strings.Contains(err.Error(), "unable to read client-key"): + return UserError{ + UserName: userName, + Reason: "Unable to read client key. Please ensure the key file exists and is readable.", + } + case strings.Contains(err.Error(), "unable to read certificate-authority"): + return ClusterError{ + ClusterName: clusterName, + Reason: "Unable to read certificate authority. Please ensure the CA file exists and is readable.", + } + case strings.Contains(err.Error(), "unable to read token"): + return UserError{ + UserName: userName, + Reason: "Unable to read token. Please ensure the token file exists and is readable.", + } + default: + return ContextError{ContextName: contextName, Reason: fmt.Sprintf("Error loading config: %v", err)} + } } -// createExecConfig creates an exec config from the given value. -// It returns the created exec config and any errors that occurred. -// It returns a valid exec config if the exec config is created. -// It returns nil if the exec config cannot be created. -func createExecConfig(value interface{}) *api.ExecConfig { - execData, ok := value.(map[interface{}]interface{}) - if !ok { - return nil +// checkBase64Errors checks the base64 errors in the kubeconfig. +func checkBase64Errors(kubeconfig map[string]interface{}, contextName, clusterName, userName string) error { + var errs []error + + // Check user data + userDetails, _ := getUser(kubeconfig, userName) + if userMap, ok := userDetails["user"].(map[interface{}]interface{}); ok { + errs = append(errs, checkUserBase64Fields(userMap, userName)...) } - execConfig := &api.ExecConfig{} - execConfig.Command, _ = execData["command"].(string) + // Check cluster data + clusterDetails, _ := getCluster(kubeconfig, clusterName) + if clusterMap, ok := clusterDetails["cluster"].(map[interface{}]interface{}); ok { + errs = append(errs, checkClusterBase64Fields(clusterMap, clusterName)...) + } - if args, ok := execData["args"].([]interface{}); ok { - execConfig.Args = make([]string, len(args)) - for i, arg := range args { - execConfig.Args[i], _ = arg.(string) - } + if len(errs) > 0 { + return Base64Error{ContextName: contextName, ClusterName: clusterName, UserName: userName, Errors: errs} } - if env, ok := execData["env"].([]interface{}); ok { - execConfig.Env = make([]api.ExecEnvVar, len(env)) + return nil +} + +// checkUserBase64Fields checks the base64 errors in the user data. +func checkUserBase64Fields(userMap map[interface{}]interface{}, userName string) []error { + var errs []error + + base64Fields := []string{"client-certificate-data", "client-key-data"} - for i, e := range env { - if envMap, ok := e.(map[interface{}]interface{}); ok { - execConfig.Env[i].Name, _ = envMap["name"].(string) - execConfig.Env[i].Value, _ = envMap["value"].(string) + for _, field := range base64Fields { + if value, ok := userMap[field].(string); ok { + if _, err := base64.StdEncoding.DecodeString(value); err != nil { + errs = append(errs, UserError{ + UserName: userName, + Reason: fmt.Sprintf("Invalid base64 encoding in %s. Please ensure it's correctly encoded.", field), + }) } } } - return execConfig + return errs } -// createExtensions creates extensions from the given value. -func createExtensions(value interface{}) map[string]k8sruntime.Object { - extensions, ok := value.(map[interface{}]interface{}) - if !ok { - return nil +// checkClusterBase64Fields checks the base64 errors in the cluster data. +func checkClusterBase64Fields(clusterMap map[interface{}]interface{}, clusterName string) []error { + var errs []error + + if value, ok := clusterMap["certificate-authority-data"].(string); ok { + if _, err := base64.StdEncoding.DecodeString(value); err != nil { + errs = append(errs, ClusterError{ + ClusterName: clusterName, + Reason: "Invalid base64 encoding in certificate-authority-data. Please ensure it's correctly encoded.", + }) + } } - result := make(map[string]k8sruntime.Object) - - for k, v := range extensions { - if key, ok := k.(string); ok { - if obj, ok := v.(k8sruntime.Object); ok { - result[key] = obj - } else { - result[key] = &CustomObject{ - TypeMeta: metav1.TypeMeta{ - Kind: "CustomObject", - APIVersion: "v1", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: key, - }, - CustomName: key, - } - } + return errs +} + +// toStringKeyMap converts the map with interface keys to a map with string keys. +func toStringKeyMap(m map[interface{}]interface{}) map[interface{}]interface{} { + result := make(map[interface{}]interface{}) + + for k, v := range m { + switch key := k.(type) { + case string: + result[key] = v + default: + result[fmt.Sprintf("%v", k)] = v } } return result } -// createKubeContext creates a kube context from the given context data. -func createKubeContext(contextData map[interface{}]interface{}) *api.Context { - kubeContext := &api.Context{} +// getCluster gets the cluster details from the kubeconfig. +func getCluster(kubeconfig map[string]interface{}, clusterName string) (map[interface{}]interface{}, error) { + clusters := kubeconfig["clusters"].([]interface{}) + + for _, cluster := range clusters { + clusterMap, ok := cluster.(map[interface{}]interface{}) + if !ok { + continue + } - if cluster, ok := contextData["cluster"].(string); ok { - kubeContext.Cluster = cluster + if clusterMap["name"] == clusterName { + return clusterMap, nil + } } - if namespace, ok := contextData["namespace"].(string); ok { - kubeContext.Namespace = namespace + return nil, fmt.Errorf("cluster %s not found", clusterName) +} + +// getUser gets the user details from the kubeconfig. +func getUser(kubeconfig map[string]interface{}, userName string) (map[interface{}]interface{}, error) { + users, ok := kubeconfig["users"].([]interface{}) + if !ok { + return nil, fmt.Errorf("invalid or missing users in kubeconfig") } - if user, ok := contextData["user"].(string); ok { - kubeContext.AuthInfo = user + for _, user := range users { + userMap, ok := user.(map[interface{}]interface{}) + if !ok { + continue + } + + if userMap["name"] == userName { + return userMap, nil + } + } + + return nil, fmt.Errorf("user %s not found", userName) +} + +// createKubeConfig creates a kubeconfig from the given context, cluster, and user. +func createKubeConfig( + contextName string, + context, + cluster, + user map[interface{}]interface{}, +) map[string]interface{} { + kubeconfig := make(map[string]interface{}) + kubeconfig["contexts"] = []interface{}{context} + kubeconfig["clusters"] = []interface{}{cluster} + kubeconfig["users"] = []interface{}{user} + kubeconfig["apiVersion"] = "v1" + kubeconfig["kind"] = "Config" + kubeconfig["current-context"] = contextName + + return kubeconfig +} + +// convertToContext converts the config to a context. +// contextName is the name of the context. +// clientConfig is the client config. +// source is the source of the kubeconfig, i.e where the kubeconfig came from. +// It can be KubeConfig, DynamicCluster, or InCluster. +// skipProxySetup is a flag to skip proxy setup. +func convertToContext(contextName string, clientConfig *api.Config, source int, skipProxySetup bool) (Context, error) { + context, exists := clientConfig.Contexts[contextName] + if !exists { + return Context{}, ContextError{ + ContextName: contextName, + Reason: "context not found in loaded config", + } } - // Handle extensions if present - if extensions, ok := contextData["extensions"].(map[interface{}]interface{}); ok { - kubeContext.Extensions = make(map[string]k8sruntime.Object) + cluster, exists := clientConfig.Clusters[context.Cluster] + if !exists { + return Context{}, ClusterError{ + ClusterName: context.Cluster, + Reason: "cluster not found in loaded config", + } + } - for k, v := range extensions { - if key, ok := k.(string); ok { - if obj, ok := v.(k8sruntime.Object); ok { - kubeContext.Extensions[key] = obj - } else { - // If the extension is not already a runtime.Object, - // wrap it in a CustomObject - kubeContext.Extensions[key] = &CustomObject{ - TypeMeta: metav1.TypeMeta{ - Kind: "CustomObject", - APIVersion: "v1", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: key, - }, - CustomName: key, - } - } - } + authInfo := clientConfig.AuthInfos[context.AuthInfo] + + // Make contextName DNS friendly. + contextName = makeDNSFriendly(contextName) + + newContext := Context{ + Name: contextName, + KubeContext: context, + Cluster: cluster, + AuthInfo: authInfo, + Source: source, + } + + if !skipProxySetup { + err := newContext.SetupProxy() + if err != nil { + return Context{}, ContextError{ContextName: contextName, Reason: fmt.Sprintf("couldn't setup proxy: %v", err)} } } - return kubeContext + return newContext, nil } // LoadContextsFromAPIConfig loads contexts from the given api.Config. @@ -650,27 +806,7 @@ func LoadContextsFromAPIConfig(config *api.Config, skipProxySetup bool) ([]Conte return contexts, errors } -// LoadContextsFromMultipleFiles loads contexts from the given kubeconfig files. -func LoadContextsFromMultipleFiles(kubeConfigs string, source int) ([]Context, error) { - var contexts []Context - - var errs []error - - kubeConfigPaths := splitKubeConfigPath(kubeConfigs) - for _, kubeConfigPath := range kubeConfigPaths { - kubeConfigPath := kubeConfigPath - - kubeConfigContexts, err := LoadContextsFromFile(kubeConfigPath, source) - if err != nil { - errs = append(errs, err...) - } - - contexts = append(contexts, kubeConfigContexts...) - } - - return contexts, errors.Join(errs...) -} - +// splitKubeConfigPath splits the kubeconfig path by the delimiter. func splitKubeConfigPath(path string) []string { delimiter := ":" if runtime.GOOS == "windows" { @@ -725,12 +861,15 @@ func GetInClusterContext(oidcIssuerURL string, // LoadAndStoreKubeConfigs loads contexts from the given kubeconfig files and // stores them in the given context store. +// It stores the valid contexts and returns the errors if any. // Note: No need to remove contexts from the store, since // adding a context with the same name will overwrite the old one. func LoadAndStoreKubeConfigs(kubeConfigStore ContextStore, kubeConfigs string, source int) error { - kubeConfigContexts, err := LoadContextsFromMultipleFiles(kubeConfigs, source) + var errs []error //nolint:prealloc + + kubeConfigContexts, contextErrors, err := LoadContextsFromMultipleFiles(kubeConfigs, source) if err != nil { - return err + return fmt.Errorf("error loading kubeconfig files: %v", err) } for _, kubeConfigContext := range kubeConfigContexts { @@ -738,11 +877,15 @@ func LoadAndStoreKubeConfigs(kubeConfigStore ContextStore, kubeConfigs string, s err := kubeConfigStore.AddContext(&kubeConfigContext) if err != nil { - return err + errs = append(errs, err) } } - return nil + for _, contextError := range contextErrors { + errs = append(errs, fmt.Errorf("error in context %s: %v", contextError.ContextName, contextError.Error)) + } + + return errors.Join(errs...) } // makeDNSFriendly converts a string to a DNS-friendly format. @@ -752,213 +895,3 @@ func makeDNSFriendly(name string) string { return name } - -// SetClusterField sets a cluster field in the given cluster. -// It returns an error if the field cannot be set. -func SetClusterField(cluster *api.Cluster, fieldName string, value interface{}) error { - switch fieldName { - case "server", "certificate-authority", "proxy-url", "tls-server-name": - return setClusterStringField(cluster, fieldName, value) - case "insecure-skip-tls-verify", "disable-compression": - return setClusterBoolField(cluster, fieldName, value) - case "certificate-authority-data": - return setClusterBase64Data(cluster, fieldName, value) - case "extensions": - return setClusterExtensions(cluster, value) - default: - return DataError{Field: fieldName, Reason: "unknown field for cluster"} - } -} - -// setClusterStringField sets a string field in the given cluster. -func setClusterStringField(cluster *api.Cluster, fieldName string, value interface{}) error { - strValue, ok := value.(string) - if !ok { - return DataError{Field: fieldName, Reason: "expected string, got different type"} - } - - switch fieldName { - case "server": - cluster.Server = strValue - case "certificate-authority": - cluster.CertificateAuthority = strValue - case "proxy-url": - cluster.ProxyURL = strValue - case "tls-server-name": - cluster.TLSServerName = strValue - default: - return DataError{Field: fieldName, Reason: "unknown string field for cluster"} - } - - return nil -} - -// setClusterBoolField sets a boolean field in the given cluster. -func setClusterBoolField(cluster *api.Cluster, fieldName string, value interface{}) error { - boolValue, ok := value.(bool) - if !ok { - return DataError{Field: fieldName, Reason: "expected bool, got different type"} - } - - switch fieldName { - case "insecure-skip-tls-verify": - cluster.InsecureSkipTLSVerify = boolValue - case "disable-compression": - cluster.DisableCompression = boolValue - default: - return DataError{Field: fieldName, Reason: "unknown string field for cluster"} - } - - return nil -} - -// setClusterBase64Data sets a base64 encoded data field in the given cluster. -func setClusterBase64Data(cluster *api.Cluster, fieldName string, value interface{}) error { - data, ok := value.(string) - if !ok { - return DataError{Field: fieldName, Reason: "expected string, got different type"} - } - - decoded, err := base64.StdEncoding.DecodeString(data) - if err != nil { - return DataError{Field: fieldName, Reason: "invalid base64 encoding"} - } - - cluster.CertificateAuthorityData = decoded - - return nil -} - -// setClusterExtensions sets the extensions field in the given cluster. -func setClusterExtensions(cluster *api.Cluster, value interface{}) error { - extensions := createExtensions(value) - if extensions == nil { - return DataError{Field: "extensions", Reason: "invalid extensions data"} - } - - cluster.Extensions = extensions - - return nil -} - -// SetAuthInfoField sets an auth info field in the given auth info. -// It returns an error if the field cannot be set. -func SetAuthInfoField(authInfo *api.AuthInfo, fieldName string, value interface{}) error { - switch fieldName { - case "client-certificate-data", "client-key-data": - return setBase64Data(authInfo, fieldName, value) - case "client-certificate", "client-key", "token", "tokenFile", "impersonate", "username", "password": - return setStringField(authInfo, fieldName, value) - case "impersonate-groups": - return setStringSliceField(authInfo, fieldName, value) - case "impersonate-user-extra": - return setMapStringStringSliceField(authInfo, fieldName, value) - case "exec": - return setExecField(authInfo, value) - case "auth-provider": - return setAuthProviderField(authInfo, value) - default: - return DataError{Field: fieldName, Reason: "unknown field for auth info"} - } -} - -// setBase64Data sets a base64 data field in the given auth info. -func setBase64Data(authInfo *api.AuthInfo, fieldName string, value interface{}) error { - data, ok := value.(string) - if !ok { - return DataError{Field: fieldName, Reason: "expected string, got different type"} - } - - decoded, err := base64.StdEncoding.DecodeString(data) - if err != nil { - return DataError{Field: fieldName, Reason: "invalid base64 encoding"} - } - - switch fieldName { - case "client-certificate-data": - authInfo.ClientCertificateData = decoded - case "client-key-data": - authInfo.ClientKeyData = decoded - default: - return DataError{Field: fieldName, Reason: "unknown base64 field for auth info"} - } - - return nil -} - -// setStringField sets a string field in the given auth info. -func setStringField(authInfo *api.AuthInfo, fieldName string, value interface{}) error { - strValue, ok := value.(string) - if !ok { - return DataError{Field: fieldName, Reason: "expected string, got different type"} - } - - switch fieldName { - case "client-certificate": - authInfo.ClientCertificate = strValue - case "client-key": - authInfo.ClientKey = strValue - case "token": - authInfo.Token = strValue - case "tokenFile": - authInfo.TokenFile = strValue - case "impersonate": - authInfo.Impersonate = strValue - case "username": - authInfo.Username = strValue - case "password": - authInfo.Password = strValue - default: - return DataError{Field: fieldName, Reason: "unknown string field for auth info"} - } - - return nil -} - -// setStringSliceField sets an impersonate groups field in the given auth info. -func setStringSliceField(authInfo *api.AuthInfo, fieldName string, value interface{}) error { - sliceValue, ok := value.([]string) - if !ok { - return DataError{Field: fieldName, Reason: "expected []string, got different type"} - } - - authInfo.ImpersonateGroups = sliceValue - - return nil -} - -// setMapStringStringSliceField sets an impersonate user extra field in the given auth info. -func setMapStringStringSliceField(authInfo *api.AuthInfo, fieldName string, value interface{}) error { - mapValue, ok := value.(map[string][]string) - if !ok { - return DataError{Field: fieldName, Reason: "expected map[string][]string, got different type"} - } - - authInfo.ImpersonateUserExtra = mapValue - - return nil -} - -// setExecField sets an exec field in the given auth info. -func setExecField(authInfo *api.AuthInfo, value interface{}) error { - execConfig, ok := value.(map[interface{}]interface{}) - if !ok { - return DataError{Field: "exec", Reason: "invalid exec configuration"} - } - - authInfo.Exec = createExecConfig(execConfig) - - return nil -} - -// setAuthProviderField sets an auth provider field in the given auth info. -func setAuthProviderField(authInfo *api.AuthInfo, value interface{}) error { - providerConfig, ok := value.(map[interface{}]interface{}) - if !ok { - return DataError{Field: "auth-provider", Reason: "invalid auth provider configuration"} - } - - authInfo.AuthProvider = createAuthProvider(providerConfig) - - return nil -} diff --git a/backend/pkg/kubeconfig/kubeconfig_test.go b/backend/pkg/kubeconfig/kubeconfig_test.go index e75f3b20d8..4c78b4baae 100644 --- a/backend/pkg/kubeconfig/kubeconfig_test.go +++ b/backend/pkg/kubeconfig/kubeconfig_test.go @@ -10,9 +10,10 @@ import ( "github.com/headlamp-k8s/headlamp/backend/pkg/config" "github.com/headlamp-k8s/headlamp/backend/pkg/kubeconfig" + "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "k8s.io/client-go/tools/clientcmd/api" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) const kubeConfigFilePath = "./test_data/kubeconfig1" @@ -49,34 +50,38 @@ func TestLoadContextsFromKubeConfigFile(t *testing.T) { t.Run("valid_file", func(t *testing.T) { kubeConfigFile := kubeConfigFilePath - contexts, errs := kubeconfig.LoadContextsFromFile(kubeConfigFile, kubeconfig.KubeConfig) - require.Empty(t, errs, "Expected no errors for valid file") + contexts, contextErrors, err := kubeconfig.LoadContextsFromFile(kubeConfigFile, kubeconfig.KubeConfig) + require.NoError(t, err, "Expected no error for valid file") + require.Empty(t, contextErrors, "Expected no context errors for valid file") require.Equal(t, 2, len(contexts), "Expected 2 contexts from valid file") }) t.Run("invalid_file", func(t *testing.T) { kubeConfigFile := "invalid_kubeconfig" - contexts, errs := kubeconfig.LoadContextsFromFile(kubeConfigFile, kubeconfig.KubeConfig) - require.NotEmpty(t, errs, "Expected errors for invalid file") + contexts, contextErrors, err := kubeconfig.LoadContextsFromFile(kubeConfigFile, kubeconfig.KubeConfig) + require.Error(t, err, "Expected error for invalid file") + require.Empty(t, contextErrors, "Expected no context errors for invalid file") require.Empty(t, contexts, "Expected no contexts from invalid file") }) t.Run("autherror", func(t *testing.T) { kubeConfigFile := "./test_data/kubeconfig_autherr" - contexts, errs := kubeconfig.LoadContextsFromFile(kubeConfigFile, kubeconfig.KubeConfig) - require.NotEmpty(t, errs, "Expected errors for invalid auth file") - require.Empty(t, contexts, "Expected no contexts from invalid auth file") + contexts, contextErrors, err := kubeconfig.LoadContextsFromFile(kubeConfigFile, kubeconfig.KubeConfig) + require.NoError(t, err, "Expected no error for auth error file") + require.NotEmpty(t, contextErrors, "Expected context errors for invalid auth file") + require.Equal(t, contextErrors[0].ContextName, "invalid-context") + require.Equal(t, 2, len(contexts), "Expected 1 context from invalid auth file") }) t.Run("partially_valid_contexts", func(t *testing.T) { kubeConfigFile := "./test_data/kubeconfig_partialcontextvalid" - contexts, errs := kubeconfig.LoadContextsFromFile(kubeConfigFile, kubeconfig.KubeConfig) - require.NotEmpty(t, errs, "Expected some errors for partially valid file") - require.NotEmpty(t, contexts, "Expected some valid contexts from partially valid file") - require.Equal(t, 1, len(contexts), "Expected one context from the partially valid file") + contexts, contextErrors, err := kubeconfig.LoadContextsFromFile(kubeConfigFile, kubeconfig.KubeConfig) + require.NoError(t, err, "Expected no error for partially valid file") + require.NotEmpty(t, contextErrors, "Expected some context errors for partially valid file") + require.Equal(t, 3, len(contexts), "Expected 3 contexts from the partially valid file") require.Equal(t, "valid-context", contexts[0].Name, "Expected context name to be 'valid-context'") }) } @@ -118,16 +123,15 @@ func TestContext(t *testing.T) { func TestLoadContextsFromBase64String(t *testing.T) { t.Run("valid_base64", func(t *testing.T) { - // Read the content of the kubeconfig file kubeConfigFile := kubeConfigFilePath kubeConfigContent, err := os.ReadFile(kubeConfigFile) require.NoError(t, err) - // Encode the content using base64 encoding base64String := base64.StdEncoding.EncodeToString(kubeConfigContent) - contexts, errs := kubeconfig.LoadContextsFromBase64String(base64String, kubeconfig.DynamicCluster) - require.Empty(t, errs, "Expected no errors for valid base64") + contexts, contextErrors, err := kubeconfig.LoadContextsFromBase64String(base64String, kubeconfig.DynamicCluster) + require.NoError(t, err, "Expected no error for valid base64") + require.Empty(t, contextErrors, "Expected no context errors for valid base64") require.Equal(t, 2, len(contexts), "Expected 2 contexts from valid base64") assert.Equal(t, kubeconfig.DynamicCluster, contexts[0].Source) }) @@ -136,13 +140,13 @@ func TestLoadContextsFromBase64String(t *testing.T) { invalidBase64String := "invalid_base64" source := 2 - contexts, errs := kubeconfig.LoadContextsFromBase64String(invalidBase64String, source) - require.NotEmpty(t, errs, "Expected errors for invalid base64") + contexts, contextErrors, err := kubeconfig.LoadContextsFromBase64String(invalidBase64String, source) + require.Error(t, err, "Expected error for invalid base64") + require.Empty(t, contextErrors, "Expected no context errors for invalid base64") require.Empty(t, contexts, "Expected no contexts from invalid base64") }) t.Run("partially_valid_base64", func(t *testing.T) { - // Create a partially valid kubeconfig content partiallyValidContent := ` apiVersion: v1 kind: Config @@ -166,149 +170,345 @@ users: ` base64String := base64.StdEncoding.EncodeToString([]byte(partiallyValidContent)) - contexts, errs := kubeconfig.LoadContextsFromBase64String(base64String, kubeconfig.DynamicCluster) - require.NotEmpty(t, errs, "Expected some errors for partially valid base64") - require.NotEmpty(t, contexts, "Expected some valid contexts from partially valid base64") - require.Equal(t, 1, len(contexts), "Expected one valid context from partially valid base64") + contexts, contextErrors, err := kubeconfig.LoadContextsFromBase64String(base64String, kubeconfig.DynamicCluster) + require.NoError(t, err, "Expected no error for partially valid base64") + require.NotEmpty(t, contextErrors, "Expected some context errors for partially valid base64") + require.Equal(t, 2, len(contexts), "Expected 2 valid contexts from partially valid base64") assert.Equal(t, "valid-context", contexts[0].Name, "Expected context name to be 'valid-context'") }) } -func TestSetClusterField(t *testing.T) { - cluster := &api.Cluster{} - - t.Run("set insecure-skip-tls-verify", func(t *testing.T) { - err := kubeconfig.SetClusterField(cluster, "insecure-skip-tls-verify", true) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - if !cluster.InsecureSkipTLSVerify { - t.Error("Expected InsecureSkipTLSVerify to be true") - } - }) +func TestUnmarshalKubeconfig(t *testing.T) { + tests := []struct { + name string + input []byte + want map[string]interface{} + wantErr bool + }{ + { + name: "Valid YAML", + input: []byte(`apiVersion: v1 +kind: Config +contexts: +- name: test-context + context: + cluster: test-cluster + user: test-user`), + want: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Config", + "contexts": []interface{}{ + map[interface{}]interface{}{ + "name": "test-context", + "context": map[interface{}]interface{}{ + "cluster": "test-cluster", + "user": "test-user", + }, + }, + }, + }, + wantErr: false, + }, + { + name: "Invalid YAML", + input: []byte(`invalid: yaml: content`), + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := kubeconfig.UnmarshalKubeconfig(tt.input) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} - t.Run("set disable-compression", func(t *testing.T) { - err := kubeconfig.SetClusterField(cluster, "disable-compression", true) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } +func TestGetContextsFromKubeconfig(t *testing.T) { + tests := []struct { + name string + kubeconfig map[string]interface{} + want []interface{} + wantErr bool + }{ + { + name: "Valid contexts", + kubeconfig: map[string]interface{}{ + "contexts": []interface{}{ + map[string]interface{}{ + "name": "context1", + }, + map[string]interface{}{ + "name": "context2", + }, + }, + }, + want: []interface{}{ + map[string]interface{}{ + "name": "context1", + }, + map[string]interface{}{ + "name": "context2", + }, + }, + wantErr: false, + }, + { + name: "Missing contexts", + kubeconfig: map[string]interface{}{ + "clusters": []interface{}{}, + }, + want: nil, + wantErr: true, + }, + { + name: "Invalid contexts type", + kubeconfig: map[string]interface{}{ + "contexts": "invalid", + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := kubeconfig.GetContextsFromKubeconfig(tt.kubeconfig) + if tt.wantErr { + assert.Error(t, err) + assert.Nil(t, got) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} - if !cluster.DisableCompression { - t.Error("Expected DisableCompression to be true") +func TestErrorTypes(t *testing.T) { + t.Run("ContextError", func(t *testing.T) { + err := kubeconfig.ContextError{ + ContextName: "test-context", + Reason: "test reason", } + assert.Equal(t, "Error in context 'test-context': test reason", err.Error()) }) - t.Run("invalid bool value", func(t *testing.T) { - err := kubeconfig.SetClusterField(cluster, "insecure-skip-tls-verify", "not a bool") - if err == nil { - t.Fatal("Expected an error, got nil") + t.Run("ClusterError", func(t *testing.T) { + err := kubeconfig.ClusterError{ + ClusterName: "test-cluster", + Reason: "test reason", } + assert.Equal(t, "Error in cluster 'test-cluster': test reason", err.Error()) }) - t.Run("set certificate-authority-data", func(t *testing.T) { - validBase64 := base64.StdEncoding.EncodeToString([]byte("test data")) - - err := kubeconfig.SetClusterField(cluster, "certificate-authority-data", validBase64) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - if string(cluster.CertificateAuthorityData) != "test data" { - t.Errorf("Expected 'test data', got '%s'", string(cluster.CertificateAuthorityData)) + t.Run("UserError", func(t *testing.T) { + err := kubeconfig.UserError{ + UserName: "test-user", + Reason: "test reason", } + assert.Equal(t, "Error in user 'test-user': test reason", err.Error()) }) - t.Run("invalid base64 data", func(t *testing.T) { - err := kubeconfig.SetClusterField(cluster, "certificate-authority-data", "not base64") - if err == nil { - t.Fatal("Expected an error, got nil") + t.Run("DataError", func(t *testing.T) { + err := kubeconfig.DataError{ + Field: "test-field", + Reason: "test reason", } + assert.Equal(t, "Error in field 'test-field': test reason", err.Error()) }) - t.Run("invalid extensions", func(t *testing.T) { - err := kubeconfig.SetClusterField(cluster, "extensions", "not a map") - if err == nil { - t.Fatal("Expected an error, got nil") + t.Run("Base64Error", func(t *testing.T) { + err := kubeconfig.Base64Error{ + ContextName: "test-context", + ClusterName: "test-cluster", + UserName: "test-user", + Errors: []error{ + kubeconfig.UserError{UserName: "test-user", Reason: "invalid base64"}, + kubeconfig.ClusterError{ClusterName: "test-cluster", Reason: "invalid base64"}, + }, } + expected := "Base64 decoding errors in context 'test-context', cluster 'test-cluster', user 'test-user':\n" + + "Error in user 'test-user': invalid base64\n" + + "Error in cluster 'test-cluster': invalid base64" + assert.Equal(t, expected, err.Error()) }) } -//nolint:funlen -func TestSetAuthInfoField(t *testing.T) { - authInfo := &api.AuthInfo{} - - t.Run("set client-certificate-data", func(t *testing.T) { - validBase64 := base64.StdEncoding.EncodeToString([]byte("test cert")) - - err := kubeconfig.SetAuthInfoField(authInfo, "client-certificate-data", validBase64) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - if string(authInfo.ClientCertificateData) != "test cert" { - t.Errorf("Expected 'test cert', got '%s'", string(authInfo.ClientCertificateData)) - } +func TestCustomObjectDeepCopy(t *testing.T) { + original := &kubeconfig.CustomObject{ + TypeMeta: metav1.TypeMeta{ + Kind: "CustomObject", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-object", + }, + CustomName: "test-custom-name", + } + + t.Run("DeepCopyObject", func(t *testing.T) { + copied := original.DeepCopyObject() + assert.Equal(t, original, copied) + assert.NotSame(t, original, copied) }) - t.Run("set impersonate-groups", func(t *testing.T) { - groups := []string{"group1", "group2"} - - err := kubeconfig.SetAuthInfoField(authInfo, "impersonate-groups", groups) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - if len(authInfo.ImpersonateGroups) != 2 || - authInfo.ImpersonateGroups[0] != "group1" || - authInfo.ImpersonateGroups[1] != "group2" { - t.Errorf("Expected [group1 group2], got %v", authInfo.ImpersonateGroups) - } + t.Run("DeepCopy", func(t *testing.T) { + copied := original.DeepCopy() + assert.Equal(t, original, copied) + assert.NotSame(t, original, copied) + assert.Equal(t, original.CustomName, copied.CustomName) }) - t.Run("set impersonate-user-extra", func(t *testing.T) { - extra := map[string][]string{ - "key1": {"value1", "value2"}, - "key2": {"value3"}, - } - - err := kubeconfig.SetAuthInfoField(authInfo, "impersonate-user-extra", extra) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - if len(authInfo.ImpersonateUserExtra) != 2 || - len(authInfo.ImpersonateUserExtra["key1"]) != 2 || - authInfo.ImpersonateUserExtra["key2"][0] != "value3" { - t.Errorf("Expected map[key1:[value1 value2] key2:[value3]], got %v", authInfo.ImpersonateUserExtra) - } + t.Run("DeepCopy with nil", func(t *testing.T) { + var nilObj *kubeconfig.CustomObject + copied := nilObj.DeepCopy() + assert.Nil(t, copied) }) +} - t.Run("set auth-provider", func(t *testing.T) { - provider := map[interface{}]interface{}{ - "name": "oidc", - "config": map[interface{}]interface{}{ - "client-id": "my-client", - "client-secret": "my-secret", +//nolint:funlen +func TestHandleConfigLoadError(t *testing.T) { + testKubeconfig := map[string]interface{}{ + "clusters": []interface{}{ + map[interface{}]interface{}{ + "name": "test-cluster", + "cluster": map[interface{}]interface{}{ + "certificate-authority-data": "invalid-base64", + }, }, - } - - err := kubeconfig.SetAuthInfoField(authInfo, "auth-provider", provider) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - if authInfo.AuthProvider == nil || - authInfo.AuthProvider.Name != "oidc" || - authInfo.AuthProvider.Config["client-id"] != "my-client" { - t.Errorf("Expected OIDC auth provider, got %v", authInfo.AuthProvider) - } - }) - - t.Run("invalid field", func(t *testing.T) { - err := kubeconfig.SetAuthInfoField(authInfo, "invalid-field", "some value") - if err == nil { - t.Fatal("Expected an error, got nil") - } - }) + }, + "users": []interface{}{ + map[interface{}]interface{}{ + "name": "test-user", + "user": map[interface{}]interface{}{ + "client-certificate-data": "invalid-base64", + "client-key-data": "invalid-base64", + }, + }, + }, + } + + tests := []struct { + name string + err error + contextName string + clusterName string + userName string + kubeconfig map[string]interface{} + want error + }{ + { + name: "illegal base64", + err: errors.New("illegal base64 data"), + contextName: "test-context", + clusterName: "test-cluster", + userName: "test-user", + kubeconfig: testKubeconfig, + want: kubeconfig.Base64Error{ + ContextName: "test-context", + ClusterName: "test-cluster", + UserName: "test-user", + Errors: []error{ + kubeconfig.UserError{ + UserName: "test-user", + Reason: "Invalid base64 encoding in client-certificate-data. Please ensure it's correctly encoded.", + }, + kubeconfig.UserError{ + UserName: "test-user", + Reason: "Invalid base64 encoding in client-key-data. Please ensure it's correctly encoded.", + }, + kubeconfig.ClusterError{ + ClusterName: "test-cluster", + Reason: "Invalid base64 encoding in certificate-authority-data. Please ensure it's correctly encoded.", + }, + }, + }, + }, + { + name: "no server found", + err: errors.New("no server found"), + contextName: "test-context", + clusterName: "test-cluster", + userName: "test-user", + kubeconfig: testKubeconfig, + want: kubeconfig.ClusterError{ + ClusterName: "test-cluster", + Reason: "No server URL specified. Please check the cluster configuration.", + }, + }, + { + name: "unable to read client-cert", + err: errors.New("unable to read client-cert"), + contextName: "test-context", + clusterName: "test-cluster", + userName: "test-user", + kubeconfig: testKubeconfig, + want: kubeconfig.UserError{ + UserName: "test-user", + Reason: "Unable to read client certificate. Please ensure the certificate file exists and is readable.", + }, + }, + { + name: "unable to read client-key", + err: errors.New("unable to read client-key"), + contextName: "test-context", + clusterName: "test-cluster", + userName: "test-user", + kubeconfig: testKubeconfig, + want: kubeconfig.UserError{ + UserName: "test-user", + Reason: "Unable to read client key. Please ensure the key file exists and is readable.", + }, + }, + { + name: "unable to read certificate-authority", + err: errors.New("unable to read certificate-authority"), + contextName: "test-context", + clusterName: "test-cluster", + userName: "test-user", + kubeconfig: testKubeconfig, + want: kubeconfig.ClusterError{ + ClusterName: "test-cluster", + Reason: "Unable to read certificate authority. Please ensure the CA file exists and is readable.", + }, + }, + { + name: "unable to read token", + err: errors.New("unable to read token"), + contextName: "test-context", + clusterName: "test-cluster", + userName: "test-user", + kubeconfig: testKubeconfig, + want: kubeconfig.UserError{ + UserName: "test-user", + Reason: "Unable to read token. Please ensure the token file exists and is readable.", + }, + }, + { + name: "default error", + err: errors.New("some other error"), + contextName: "test-context", + clusterName: "test-cluster", + userName: "test-user", + kubeconfig: testKubeconfig, + want: kubeconfig.ContextError{ + ContextName: "test-context", + Reason: "Error loading config: some other error", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := kubeconfig.HandleConfigLoadError(tt.err, tt.contextName, tt.clusterName, tt.userName, tt.kubeconfig) + assert.Equal(t, tt.want, got) + }) + } } diff --git a/backend/pkg/kubeconfig/test_data/kubeconfig_partialcontextvalid b/backend/pkg/kubeconfig/test_data/kubeconfig_partialcontextvalid index 5c0899dbb7..e8ae938fed 100644 --- a/backend/pkg/kubeconfig/test_data/kubeconfig_partialcontextvalid +++ b/backend/pkg/kubeconfig/test_data/kubeconfig_partialcontextvalid @@ -4,15 +4,23 @@ clusters: - name: test-cluster cluster: server: https://test-server:6443 +- name: invalid-cluster + cluster: + server: https://test-server:6443 + certificate-authority-data: abc contexts: -- name: invalid-context - context: - cluster: test-cluster - user: invalid-user - context: cluster: test-cluster user: valid-user name: valid-context +- name: invalid-context + context: + cluster: test-cluster + user: invalid-user +- name: invalid-cluster + context: + cluster: invalid-cluster + user: invalid-user users: - name: invalid-user user: diff --git a/backend/pkg/portforward/handler_test.go b/backend/pkg/portforward/handler_test.go index 143ed1383f..ba568f7330 100644 --- a/backend/pkg/portforward/handler_test.go +++ b/backend/pkg/portforward/handler_test.go @@ -51,12 +51,13 @@ func TestStartPortForward(t *testing.T) { // load kubeconfig kubeConfigPath := getDefaultKubeConfigPath(t) - kContexts, errs := kubeconfig.LoadContextsFromFile(kubeConfigPath, kubeconfig.KubeConfig) - require.Empty(t, errs) + kContexts, contextErrors, err := kubeconfig.LoadContextsFromFile(kubeConfigPath, kubeconfig.KubeConfig) + require.NoError(t, err) + require.Empty(t, contextErrors) require.NotEmpty(t, kContexts) kc := kContexts[0] - err := kubeConfigStore.AddContext(&kc) + err = kubeConfigStore.AddContext(&kc) require.NoError(t, err) // find a pod to portforward to