Skip to content

Commit

Permalink
Kots Feature Flags (#2245)
Browse files Browse the repository at this point in the history
  • Loading branch information
jala-dx committed Oct 14, 2021
1 parent 9409f1e commit a8687a5
Show file tree
Hide file tree
Showing 4 changed files with 177 additions and 40 deletions.
1 change: 1 addition & 0 deletions kotskinds/apis/kots/v1beta1/application_types.go
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion pkg/apiserver/server.go
Expand Up @@ -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)
Expand Down
104 changes: 65 additions & 39 deletions pkg/handlers/metadata.go
Expand Up @@ -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)
110 changes: 110 additions & 0 deletions 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)
})
}

}

0 comments on commit a8687a5

Please sign in to comment.