Skip to content

Commit

Permalink
Merge pull request #4013 from replicatedhq/divolgin/sc-85778/endpoint…
Browse files Browse the repository at this point in the history
…-in-kots-to-receive-dispatch-custom

API to accept metrics from application
  • Loading branch information
divolgin authored Aug 28, 2023
2 parents a54c4b5 + 81542ce commit 3900716
Show file tree
Hide file tree
Showing 14 changed files with 383 additions and 1 deletion.
7 changes: 7 additions & 0 deletions pkg/apiserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,13 @@ func Start(params *APIServerParams) {

handlers.RegisterSessionAuthRoutes(r.PathPrefix("").Subrouter(), kotsStore, handler, policyMiddleware)

/**********************************************************************
* Session auth routes
**********************************************************************/

licenseAuthRouter := r.PathPrefix("").Subrouter()
handlers.RegisterLicenseIDAuthRoutes(licenseAuthRouter, kotsStore, handler, policyMiddleware)

// Prevent API requests that don't match anything in this router from returning UI content
r.PathPrefix("/api").Handler(handlers.StatusNotFoundHandler{})

Expand Down
72 changes: 72 additions & 0 deletions pkg/handlers/custom_metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package handlers

import (
"encoding/json"
"net/http"
"reflect"

"github.com/pkg/errors"
"github.com/replicatedhq/kots/pkg/kotsadm"
"github.com/replicatedhq/kots/pkg/logger"
"github.com/replicatedhq/kots/pkg/replicatedapp"
"github.com/replicatedhq/kots/pkg/session"
)

type SendCustomApplicationMetricsRequest struct {
Data ApplicationMetricsData `json:"data"`
}

type ApplicationMetricsData map[string]interface{}

func (h *Handler) SendCustomApplicationMetrics(w http.ResponseWriter, r *http.Request) {
if kotsadm.IsAirgap() {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("This request cannot be satisfied in airgap mode"))
return
}

license := session.ContextGetLicense(r)
app := session.ContextGetApp(r)

request := SendCustomApplicationMetricsRequest{}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
logger.Error(errors.Wrap(err, "decode request"))
w.WriteHeader(http.StatusBadRequest)
return
}

if err := validateCustomMetricsData(request.Data); err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}

err := replicatedapp.SendApplicationMetricsData(license, app, request.Data)
if err != nil {
logger.Error(errors.Wrap(err, "set application data"))
w.WriteHeader(http.StatusBadRequest)
return
}

JSON(w, http.StatusOK, "")
}

func validateCustomMetricsData(data ApplicationMetricsData) error {
if len(data) == 0 {
return errors.New("no data provided")
}

for key, val := range data {
valType := reflect.TypeOf(val)
switch valType.Kind() {
case reflect.Slice:
return errors.Errorf("%s value is an array, only scalar values are allowed", key)
case reflect.Array:
return errors.Errorf("%s value is an array, only scalar values are allowed", key)
case reflect.Map:
return errors.Errorf("%s value is a map, only scalar values are allowed", key)
}
}

return nil
}
117 changes: 117 additions & 0 deletions pkg/handlers/custom_metrics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package handlers

import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"

gomock "github.com/golang/mock/gomock"
"github.com/gorilla/mux"
apptypes "github.com/replicatedhq/kots/pkg/app/types"
"github.com/replicatedhq/kots/pkg/session"
"github.com/replicatedhq/kotskinds/apis/kots/v1beta1"
"github.com/stretchr/testify/require"
)

func Test_validateCustomMetricsData(t *testing.T) {
tests := []struct {
name string
data ApplicationMetricsData
wantErr bool
}{
{
name: "all values are valid",
data: ApplicationMetricsData{
"key1": "val1",
"key2": 6,
"key3": 6.6,
"key4": true,
},
wantErr: false,
},
{
name: "no data",
data: ApplicationMetricsData{},
wantErr: true,
},
{
name: "array value",
data: ApplicationMetricsData{
"key1": 10,
"key2": []string{"val1", "val2"},
},
wantErr: true,
},
{
name: "map value",
data: ApplicationMetricsData{
"key1": 10,
"key2": map[string]string{"key1": "val1"},
},
wantErr: true,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := validateCustomMetricsData(test.data)
if test.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}

func Test_SendCustomApplicationMetrics(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

req := require.New(t)
customMetricsData := []byte(`{"data":{"key1_string":"val1","key2_int":5,"key3_float":1.5,"key4_numeric_string":"1.6"}}`)
appID := "app-id-123"

// Mock server side

serverRouter := mux.NewRouter()
server := httptest.NewServer(serverRouter)
defer server.Close()

serverRouter.Methods("POST").Path("/application/custom-metrics").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
req.NoError(err)
req.Equal(string(customMetricsData), string(body))
req.Equal(appID, r.Header.Get("X-Replicated-InstanceID"))
w.WriteHeader(http.StatusOK)
})

// Mock kotsadm side

os.Setenv("USE_MOCK_REPORTING", "1")
defer os.Unsetenv("USE_MOCK_REPORTING")

handler := Handler{}
clientWriter := httptest.NewRecorder()
clientRequest := &http.Request{
Body: io.NopCloser(bytes.NewBuffer(customMetricsData)),
}

clientRequest = session.ContextSetLicense(clientRequest, &v1beta1.License{
Spec: v1beta1.LicenseSpec{
Endpoint: server.URL,
},
})
clientRequest = session.ContextSetApp(clientRequest, &apptypes.App{
ID: appID,
})

// Validate

handler.SendCustomApplicationMetrics(clientWriter, clientRequest)

req.Equal(http.StatusOK, clientWriter.Code)
}
6 changes: 6 additions & 0 deletions pkg/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,12 @@ func RegisterUnauthenticatedRoutes(handler *Handler, kotsStore store.Store, debu
loggingRouter.Path("/license/v1/license").Methods("GET").HandlerFunc(handler.GetPlatformLicenseCompatibility)
}

func RegisterLicenseIDAuthRoutes(r *mux.Router, kotsStore store.Store, handler KOTSHandler, middleware *policy.Middleware) {
r.Use(LoggingMiddleware, RequireValidLicenseMiddleware(kotsStore))
r.Name("SendCustomApplicationMetrics").Path("/api/v1/app/custom-metrics").Methods("POST").
HandlerFunc(middleware.EnforceLicense(handler.SendCustomApplicationMetrics))
}

func StreamJSON(c *websocket.Conn, payload interface{}) {
response, err := json.Marshal(payload)
if err != nil {
Expand Down
10 changes: 10 additions & 0 deletions pkg/handlers/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1329,6 +1329,16 @@ var HandlerPolicyTests = map[string][]HandlerPolicyTest{
ExpectStatus: http.StatusOK,
},
},
"SendCustomApplicationMetrics": {
{
Roles: []rbactypes.Role{rbac.ClusterAdminRole},
SessionRoles: []string{rbac.ClusterAdminRoleID},
Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) {
handlerRecorder.SendCustomApplicationMetrics(gomock.Any(), gomock.Any())
},
ExpectStatus: http.StatusOK,
},
},
}

type HandlerPolicyTest struct {
Expand Down
3 changes: 3 additions & 0 deletions pkg/handlers/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,4 +155,7 @@ type KOTSHandler interface {
// Helm
IsHelmManaged(w http.ResponseWriter, r *http.Request)
GetAppValuesFile(w http.ResponseWriter, r *http.Request)

// API available to applications (except legacy /license/v1/license)
SendCustomApplicationMetrics(w http.ResponseWriter, r *http.Request)
}
18 changes: 18 additions & 0 deletions pkg/handlers/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,21 @@ func RequireValidSessionQuietMiddleware(kotsStore store.Store) mux.MiddlewareFun
})
}
}

func RequireValidLicenseMiddleware(kotsStore store.Store) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
license, app, err := requireValidLicense(kotsStore, w, r)
if err != nil {
if !kotsStore.IsNotFound(err) {
logger.Error(errors.Wrapf(err, "request %q", r.RequestURI))
}
return
}

r = session.ContextSetLicense(r, license)
r = session.ContextSetApp(r, app)
next.ServeHTTP(w, r)
})
}
}
12 changes: 12 additions & 0 deletions pkg/handlers/mock/mock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 47 additions & 0 deletions pkg/handlers/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ import (
"time"

"github.com/pkg/errors"
apptypes "github.com/replicatedhq/kots/pkg/app/types"
"github.com/replicatedhq/kots/pkg/handlers/types"
"github.com/replicatedhq/kots/pkg/k8sutil"
"github.com/replicatedhq/kots/pkg/kotsutil"
"github.com/replicatedhq/kots/pkg/logger"
"github.com/replicatedhq/kots/pkg/session"
sessiontypes "github.com/replicatedhq/kots/pkg/session/types"
"github.com/replicatedhq/kots/pkg/store"
"github.com/replicatedhq/kots/pkg/util"
"github.com/replicatedhq/kotskinds/apis/kots/v1beta1"
kuberneteserrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
Expand Down Expand Up @@ -129,6 +132,50 @@ func requireValidSession(kotsStore store.Store, w http.ResponseWriter, r *http.R
return sess, nil
}

func requireValidLicense(kotsStore store.Store, w http.ResponseWriter, r *http.Request) (*v1beta1.License, *apptypes.App, error) {
if r.Method == "OPTIONS" {
return nil, nil, nil
}

licenseID := r.Header.Get("authorization")
if licenseID == "" {
err := errors.New("missing authorization header")
response := types.ErrorResponse{Error: util.StrPointer(err.Error())}
JSON(w, http.StatusUnauthorized, response)
return nil, nil, err
}

apps, err := kotsStore.ListInstalledApps()
if err != nil {
return nil, nil, errors.Wrap(err, "get all apps")
}

var license *v1beta1.License
var app *apptypes.App

for _, a := range apps {
l, err := kotsutil.LoadLicenseFromBytes([]byte(a.License))
if err != nil {
return nil, nil, errors.Wrap(err, "load license")
}

if l.Spec.LicenseID == licenseID {
license = l
app = a
break
}
}

if license == nil {
err := errors.New("license ID is not valid")
response := types.ErrorResponse{Error: util.StrPointer(err.Error())}
JSON(w, http.StatusUnauthorized, response)
return nil, nil, err
}

return license, app, nil
}

func requireValidKOTSToken(w http.ResponseWriter, r *http.Request) error {
if r.Header.Get("Authorization") == "" {
w.WriteHeader(http.StatusUnauthorized)
Expand Down
15 changes: 15 additions & 0 deletions pkg/policy/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,21 @@ func (m *Middleware) EnforceAccess(p *Policy, handler http.HandlerFunc) http.Han
}
}

func (m *Middleware) EnforceLicense(handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
license := session.ContextGetLicense(r)
if license == nil {
err := errors.New("no valid license for request")
logger.Error(err)
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(err.Error()))
return
}

handler(w, r)
}
}

// TODO: move everything below here to a shared package

type ErrorResponse struct {
Expand Down
Loading

0 comments on commit 3900716

Please sign in to comment.