Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add an endpoint to the SDK where metrics can be retrieved adhoc #101

Merged
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/actions/validate-endpoints/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,13 @@ runs:
exit 1
fi
fi

- name: Validate /app/metrics endpoint
shell: bash
run: |
appStatusMetric=$(curl -H 'Authorization: ${{ inputs.license-id }}' -s --fail --show-error localhost:8888/api/v1/app/metrics | jq '."X-Replicated-AppStatus"' | tr -d '\n')

if [ "$appStatusMetric" != "ready" ]; then
echo "Expected app status metric 'X-Replicated-AppStatus' to be 'ready', but is set to '$appStatusMetric'."
exit 1
fi
4 changes: 2 additions & 2 deletions pact/custom_metrics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
"github.com/replicatedhq/replicated-sdk/pkg/store"
)

func TestSendCustomApplicationMetrics(t *testing.T) {
func TestSendCustomAppMetrics(t *testing.T) {
// Happy path only

channelSequence := int64(1)
Expand Down Expand Up @@ -77,7 +77,7 @@ func TestSendCustomApplicationMetrics(t *testing.T) {
defer store.SetStore(nil)

if err := pact.Verify(func() error {
handlers.SendCustomApplicationMetrics(clientWriter, clientRequest)
handlers.SendCustomAppMetrics(clientWriter, clientRequest)
if clientWriter.Code != http.StatusOK {
return fmt.Errorf("expected status code %d but got %d", http.StatusOK, clientWriter.Code)
}
Expand Down
9 changes: 6 additions & 3 deletions pkg/apiserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ func Start(params APIServerParams) {
r := mux.NewRouter()
r.Use(handlers.CorsMiddleware)

// TODO: make all routes authenticated
authRouter := r.NewRoute().Subrouter()
authRouter.Use(handlers.RequireValidLicenseIDMiddleware)

r.HandleFunc("/healthz", handlers.Healthz)

// license
Expand All @@ -62,15 +66,14 @@ func Start(params APIServerParams) {
r.HandleFunc("/api/v1/app/info", handlers.GetCurrentAppInfo).Methods("GET")
r.HandleFunc("/api/v1/app/updates", handlers.GetAppUpdates).Methods("GET")
r.HandleFunc("/api/v1/app/history", handlers.GetAppHistory).Methods("GET")
authRouter.HandleFunc("/api/v1/app/metrics", handlers.GetAppMetrics).Methods("GET")
r.HandleFunc("/api/v1/app/custom-metrics", handlers.SendCustomAppMetrics).Methods("POST")

// integration
r.HandleFunc("/api/v1/integration/mock-data", handlers.EnforceMockAccess(handlers.PostIntegrationMockData)).Methods("POST")
r.HandleFunc("/api/v1/integration/mock-data", handlers.EnforceMockAccess(handlers.GetIntegrationMockData)).Methods("GET")
r.HandleFunc("/api/v1/integration/status", handlers.EnforceMockAccess(handlers.GetIntegrationStatus)).Methods("GET")

// Custom metrics
r.HandleFunc("/api/v1/app/custom-metrics", handlers.SendCustomApplicationMetrics).Methods("POST")

srv := &http.Server{
Handler: r,
Addr: ":3000",
Expand Down
72 changes: 72 additions & 0 deletions pkg/handlers/app.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
package handlers

import (
"encoding/json"
"net/http"
"reflect"
"sort"
"strings"
"time"

"github.com/pkg/errors"
appstatetypes "github.com/replicatedhq/replicated-sdk/pkg/appstate/types"
"github.com/replicatedhq/replicated-sdk/pkg/config"
"github.com/replicatedhq/replicated-sdk/pkg/heartbeat"
"github.com/replicatedhq/replicated-sdk/pkg/helm"
"github.com/replicatedhq/replicated-sdk/pkg/integration"
integrationtypes "github.com/replicatedhq/replicated-sdk/pkg/integration/types"
"github.com/replicatedhq/replicated-sdk/pkg/k8sutil"
sdklicense "github.com/replicatedhq/replicated-sdk/pkg/license"
"github.com/replicatedhq/replicated-sdk/pkg/logger"
"github.com/replicatedhq/replicated-sdk/pkg/metrics"
"github.com/replicatedhq/replicated-sdk/pkg/store"
"github.com/replicatedhq/replicated-sdk/pkg/upstream"
upstreamtypes "github.com/replicatedhq/replicated-sdk/pkg/upstream/types"
Expand Down Expand Up @@ -46,6 +50,12 @@ type AppRelease struct {
HelmReleaseNamespace string `json:"helmReleaseNamespace,omitempty"`
}

type SendCustomAppMetricsRequest struct {
Data CustomAppMetricsData `json:"data"`
}

type CustomAppMetricsData map[string]interface{}

func GetCurrentAppInfo(w http.ResponseWriter, r *http.Request) {
clientset, err := k8sutil.GetClientset()
if err != nil {
Expand Down Expand Up @@ -315,3 +325,65 @@ func mockReleaseToAppRelease(mockRelease integrationtypes.MockRelease) AppReleas

return appRelease
}

func GetAppMetrics(w http.ResponseWriter, r *http.Request) {
heartbeatInfo := heartbeat.GetHeartbeatInfo(store.GetStore())
headers := heartbeat.GetHeartbeatInfoHeaders(heartbeatInfo)

JSON(w, http.StatusOK, headers)
}

func SendCustomAppMetrics(w http.ResponseWriter, r *http.Request) {
license := store.GetStore().GetLicense()

if util.IsAirgap() {
JSON(w, http.StatusForbidden, "This request cannot be satisfied in airgap mode")
return
}

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

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

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

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

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

for key, val := range data {
valType := reflect.TypeOf(val)
if valType == nil {
return errors.Errorf("%s value is nil, only scalar values are allowed", key)
}

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
}
16 changes: 8 additions & 8 deletions pkg/handlers/custom_metrics_test.go → pkg/handlers/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ import (
"github.com/stretchr/testify/require"
)

func Test_validateCustomMetricsData(t *testing.T) {
func Test_validateCustomAppMetricsData(t *testing.T) {
tests := []struct {
name string
data ApplicationMetricsData
data CustomAppMetricsData
wantErr bool
}{
{
name: "all values are valid",
data: ApplicationMetricsData{
data: CustomAppMetricsData{
"key1": "val1",
"key2": 6,
"key3": 6.6,
Expand All @@ -24,28 +24,28 @@ func Test_validateCustomMetricsData(t *testing.T) {
},
{
name: "no data",
data: ApplicationMetricsData{},
data: CustomAppMetricsData{},
wantErr: true,
},
{
name: "array value",
data: ApplicationMetricsData{
data: CustomAppMetricsData{
"key1": 10,
"key2": []string{"val1", "val2"},
},
wantErr: true,
},
{
name: "map value",
data: ApplicationMetricsData{
data: CustomAppMetricsData{
"key1": 10,
"key2": map[string]string{"key1": "val1"},
},
wantErr: true,
},
{
name: "nil value",
data: ApplicationMetricsData{
data: CustomAppMetricsData{
"key1": nil,
"key2": 4,
},
Expand All @@ -55,7 +55,7 @@ func Test_validateCustomMetricsData(t *testing.T) {

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := validateCustomMetricsData(test.data)
err := validateCustomAppMetricsData(test.data)
if test.wantErr {
require.Error(t, err)
} else {
Expand Down
74 changes: 0 additions & 74 deletions pkg/handlers/custom_metrics.go

This file was deleted.

20 changes: 20 additions & 0 deletions pkg/handlers/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package handlers
import (
"net/http"

"github.com/replicatedhq/replicated-sdk/pkg/handlers/types"
"github.com/replicatedhq/replicated-sdk/pkg/store"
)

Expand All @@ -24,3 +25,22 @@ func EnforceMockAccess(next http.HandlerFunc) http.HandlerFunc {
next.ServeHTTP(w, r)
}
}

func RequireValidLicenseIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
licenseID := r.Header.Get("authorization")
if licenseID == "" {
response := types.ErrorResponse{Error: "missing authorization header"}
JSON(w, http.StatusUnauthorized, response)
return
}

if store.GetStore().GetLicense().Spec.LicenseID != licenseID {
response := types.ErrorResponse{Error: "license ID is not valid"}
JSON(w, http.StatusUnauthorized, response)
return
}

next.ServeHTTP(w, r)
})
}
5 changes: 5 additions & 0 deletions pkg/handlers/types/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package types

type ErrorResponse struct {
Error string `json:"error,omitempty"`
}
30 changes: 21 additions & 9 deletions pkg/heartbeat/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,38 @@ import (
)

func InjectHeartbeatInfoHeaders(req *http.Request, heartbeatInfo *types.HeartbeatInfo) {
headers := GetHeartbeatInfoHeaders(heartbeatInfo)

for key, value := range headers {
req.Header.Set(key, value)
}
}

func GetHeartbeatInfoHeaders(heartbeatInfo *types.HeartbeatInfo) map[string]string {
headers := make(map[string]string)

if heartbeatInfo == nil {
return
return headers
}

req.Header.Set("X-Replicated-K8sVersion", heartbeatInfo.K8sVersion)
req.Header.Set("X-Replicated-AppStatus", heartbeatInfo.AppStatus)
req.Header.Set("X-Replicated-ClusterID", heartbeatInfo.ClusterID)
req.Header.Set("X-Replicated-InstanceID", heartbeatInfo.InstanceID)
headers["X-Replicated-K8sVersion"] = heartbeatInfo.K8sVersion
headers["X-Replicated-AppStatus"] = heartbeatInfo.AppStatus
headers["X-Replicated-ClusterID"] = heartbeatInfo.ClusterID
headers["X-Replicated-InstanceID"] = heartbeatInfo.InstanceID

if heartbeatInfo.ChannelID != "" {
req.Header.Set("X-Replicated-DownstreamChannelID", heartbeatInfo.ChannelID)
headers["X-Replicated-DownstreamChannelID"] = heartbeatInfo.ChannelID
} else if heartbeatInfo.ChannelName != "" {
req.Header.Set("X-Replicated-DownstreamChannelName", heartbeatInfo.ChannelName)
headers["X-Replicated-DownstreamChannelName"] = heartbeatInfo.ChannelName
}

req.Header.Set("X-Replicated-DownstreamChannelSequence", strconv.FormatInt(heartbeatInfo.ChannelSequence, 10))
headers["X-Replicated-DownstreamChannelSequence"] = strconv.FormatInt(heartbeatInfo.ChannelSequence, 10)

if heartbeatInfo.K8sDistribution != "" {
req.Header.Set("X-Replicated-K8sDistribution", heartbeatInfo.K8sDistribution)
headers["X-Replicated-K8sDistribution"] = heartbeatInfo.K8sDistribution
}

return headers
}

func canReport(clientset kubernetes.Interface, namespace string, license *kotsv1beta1.License) (bool, error) {
Expand Down
Loading
Loading