diff --git a/kotskinds/apis/kots/v1beta1/application_types.go b/kotskinds/apis/kots/v1beta1/application_types.go index d8dfc9cdb5..ef910be742 100644 --- a/kotskinds/apis/kots/v1beta1/application_types.go +++ b/kotskinds/apis/kots/v1beta1/application_types.go @@ -57,6 +57,7 @@ type ApplicationSpec struct { AdditionalNamespaces []string `json:"additionalNamespaces,omitempty"` RequireMinimalRBACPrivileges bool `json:"requireMinimalRBACPrivileges,omitempty"` ProxyPublicImages bool `json:"proxyPublicImages,omitempty"` + ConsoleFeatureFlags []string `json:"consoleFeatureFlags,omitempty"` } type ApplicationPort struct { diff --git a/pkg/apiserver/server.go b/pkg/apiserver/server.go index c9fc694580..ed200e59fa 100644 --- a/pkg/apiserver/server.go +++ b/pkg/apiserver/server.go @@ -148,7 +148,7 @@ func Start(params *APIServerParams) { loggingRouter.HandleFunc("/api/v1/login", handler.Login) loggingRouter.HandleFunc("/api/v1/login/info", handler.GetLoginInfo) loggingRouter.HandleFunc("/api/v1/logout", handler.Logout) // this route uses its own auth - loggingRouter.Path("/api/v1/metadata").Methods("GET").HandlerFunc(handler.Metadata) + loggingRouter.Path("/api/v1/metadata").Methods("GET").HandlerFunc(handlers.GetMetadataHandler(handlers.GetMetaDataConfig)) loggingRouter.HandleFunc("/api/v1/oidc/login", handler.OIDCLogin) loggingRouter.HandleFunc("/api/v1/oidc/login/callback", handler.OIDCLoginCallback) diff --git a/pkg/handlers/metadata.go b/pkg/handlers/metadata.go index 0195a8675a..d157ca6366 100644 --- a/pkg/handlers/metadata.go +++ b/pkg/handlers/metadata.go @@ -2,83 +2,109 @@ package handlers import ( "context" + "fmt" "net/http" - "github.com/pkg/errors" kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1" "github.com/replicatedhq/kots/pkg/k8sutil" "github.com/replicatedhq/kots/pkg/kurl" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/util" + v1 "k8s.io/api/core/v1" kuberneteserrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/scheme" ) +const ( + appYamlKey = "application.yaml" + iconURI = "https://cdn2.iconfinder.com/data/icons/mixd/512/16_kubernetes-512.png" + metadataConfigMapName = "kotsadm-application-metadata" + upstreamUriKey = "upstreamUri" + defaultAppName = "the application" +) + +// MetadataResponse non sensitive information to be used by ui pre-login type MetadataResponse struct { IconURI string `json:"iconUri"` Name string `json:"name"` Namespace string `json:"namespace"` IsKurlEnabled bool `json:"isKurlEnabled"` UpstreamURI string `json:"upstreamUri"` + // ConsoleFeatureFlags optional flags from application.yaml used to enable ui features + ConsoleFeatureFlags []string `json:"consoleFeatureFlags"` } -// Metadata route is UNAUTHENTICATED -// It is needed for branding/some cluster flags before user is logged in. -func (h *Handler) Metadata(w http.ResponseWriter, r *http.Request) { - // This is not an authenticated request - - clientset, err := k8sutil.GetClientset() - if err != nil { - logger.Error(err) - w.WriteHeader(500) - return - } - - isKurlEnabled := kurl.IsKurl() +// GetMetadataHandler helper function that returns a http handler func that returns metadata. It takes a function that +// retrieves state information from an active k8s cluster. +func GetMetadataHandler(getK8sInfoFn MetadataK8sFn) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + metadataResponse := MetadataResponse{ + IconURI: iconURI, + Name: defaultAppName, + Namespace: util.PodNamespace, + } - brandingConfigMap, err := clientset.CoreV1().ConfigMaps(util.PodNamespace).Get(context.TODO(), "kotsadm-application-metadata", metav1.GetOptions{}) - if err != nil && !kuberneteserrors.IsNotFound(err) { - logger.Error(err) - w.WriteHeader(500) - return - } + brandingConfigMap, isKurlEnabled, err := getK8sInfoFn() + if err != nil { + // if we can't find config map in cluster, it's not an error, we still want to return a stripped down response + if kuberneteserrors.IsNotFound(err) { + logger.Info(fmt.Sprintf("config map %q not found", metadataConfigMapName)) + JSON(w, http.StatusOK, &metadataResponse) + return + } - metadataResponse := MetadataResponse{ - IconURI: "https://cdn2.iconfinder.com/data/icons/mixd/512/16_kubernetes-512.png", - Name: "the application", - Namespace: util.PodNamespace, - IsKurlEnabled: isKurlEnabled, - } + logger.Error(err) + w.WriteHeader(http.StatusServiceUnavailable) + return + } - if err == nil { - data, ok := brandingConfigMap.Data["application.yaml"] + data, ok := brandingConfigMap.Data[appYamlKey] if !ok { - logger.Error(err) - w.WriteHeader(500) + logger.Error(fmt.Errorf("%s key not found in the configmap %s", appYamlKey, metadataConfigMapName)) + w.WriteHeader(http.StatusInternalServerError) return } // parse data as a kotskind - decode := scheme.Codecs.UniversalDeserializer().Decode - obj, gvk, err := decode([]byte(data), nil, nil) + obj, gvk, err := scheme.Codecs.UniversalDeserializer().Decode([]byte(data), nil, nil) if err != nil { - logger.Error(err) - w.WriteHeader(500) + logger.Error(fmt.Errorf("failed to decode application yaml %w", err)) + w.WriteHeader(http.StatusInternalServerError) return } if gvk.Group != "kots.io" || gvk.Version != "v1beta1" || gvk.Kind != "Application" { - logger.Error(errors.New("unexpected gvk found in metadata")) - w.WriteHeader(500) + logger.Error(fmt.Errorf("expected Application crd but get %#v", gvk)) + w.WriteHeader(http.StatusInternalServerError) return } - application := obj.(*kotsv1beta1.Application) + metadataResponse.IsKurlEnabled = isKurlEnabled metadataResponse.IconURI = application.Spec.Icon metadataResponse.Name = application.Spec.Title - metadataResponse.UpstreamURI = brandingConfigMap.Data["upstreamUri"] + metadataResponse.UpstreamURI = brandingConfigMap.Data[upstreamUriKey] + metadataResponse.ConsoleFeatureFlags = application.Spec.ConsoleFeatureFlags + + JSON(w, http.StatusOK, metadataResponse) } +} - JSON(w, 200, metadataResponse) +// GetMetaDataConfig retrieves configMap from k8s used to construct metadata +func GetMetaDataConfig() (*v1.ConfigMap, bool, error) { + clientset, err := k8sutil.GetClientset() + if err != nil { + return nil, false, nil + } + + isKurlEnabled := kurl.IsKurl() + + brandingConfigMap, err := clientset.CoreV1().ConfigMaps(util.PodNamespace).Get(context.TODO(), metadataConfigMapName, metav1.GetOptions{}) + if err != nil { + return nil, false, err + } + + return brandingConfigMap, isKurlEnabled, nil } + +type MetadataK8sFn func() (*v1.ConfigMap, bool, error) diff --git a/pkg/handlers/metadata_test.go b/pkg/handlers/metadata_test.go new file mode 100644 index 0000000000..ab3b9ac028 --- /dev/null +++ b/pkg/handlers/metadata_test.go @@ -0,0 +1,110 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/replicatedhq/kots/pkg/util" + "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" +) + +type mockNotFound struct{} + +func (mockNotFound) Error() string { return "not found" } +func (mockNotFound) Status() metav1.Status { return metav1.Status{Reason: metav1.StatusReasonNotFound} } + +func Test_MetadataHandler(t *testing.T) { + configMap := `apiVersion: v1 +data: + application.yaml: | + apiVersion: kots.io/v1beta1 + kind: Application + metadata: + name: app-slug + spec: + icon: https://foo.com/icon.png + title: App Name + consoleFeatureFlags: + - feature1 + - feature2 + status: {} +kind: ConfigMap +metadata: + labels: + app.kubernetes.io/name: kotsadm + manager: kotsadm + name: kotsadm-application-metadata + namespace: default +` + + tests := []struct { + name string + funcPtr MetadataK8sFn + httpStatus int + expected MetadataResponse + }{ + { + name: "happy path feature flag test", + funcPtr: func() (*v1.ConfigMap, bool, error) { + + // parse data as a kotskind + obj, _, err := scheme.Codecs.UniversalDeserializer().Decode([]byte(configMap), nil, nil) + require.Nil(t, err) + + return obj.(*v1.ConfigMap), true, nil + + }, + expected: MetadataResponse{ + IsKurlEnabled: true, + IconURI: "https://foo.com/icon.png", + Name: "App Name", + ConsoleFeatureFlags: []string{"feature1", "feature2"}, + Namespace: util.PodNamespace, + }, + httpStatus: http.StatusOK, + }, + { + name: "cluster error", + funcPtr: func() (*v1.ConfigMap, bool, error) { + return nil, false, errors.New("wah wah wah") + }, + httpStatus: http.StatusServiceUnavailable, + }, + { + name: "cluster present, no kurl", + funcPtr: func() (*v1.ConfigMap, bool, error) { + return nil, false, &mockNotFound{} + }, + httpStatus: http.StatusOK, + expected: MetadataResponse{ + IconURI: iconURI, + Name: defaultAppName, + Namespace: util.PodNamespace, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ts := httptest.NewServer(GetMetadataHandler(test.funcPtr)) + defer ts.Close() + + response, err := http.Get(ts.URL) + require.Nil(t, err) + require.Equal(t, test.httpStatus, response.StatusCode) + if response.StatusCode != http.StatusOK { + return + } + var metadata MetadataResponse + require.Nil(t, json.NewDecoder(response.Body).Decode(&metadata)) + require.Equal(t, test.expected, metadata) + }) + } + +}