diff --git a/.gitignore b/.gitignore index ec719775..ae728ca1 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,5 @@ cmd/replicated/__debug_bin # pact pact_logs/ +pact/log/ pacts/ diff --git a/chart/templates/replicated-secret.yaml b/chart/templates/replicated-secret.yaml index 70cf3cbf..5378c70b 100644 --- a/chart/templates/replicated-secret.yaml +++ b/chart/templates/replicated-secret.yaml @@ -31,6 +31,7 @@ stringData: {{- end }} replicatedID: {{ .Values.replicatedID | default "" | quote }} appID: {{ .Values.appID | default "" | quote }} + additionalMetricsEndpoint: {{ .Values.additionalMetricsEndpoint | default "" | quote }} {{- if (.Values.integration).licenseID }} integration-license-id: {{ .Values.integration.licenseID }} {{- end }} diff --git a/chart/values.yaml.tmpl b/chart/values.yaml.tmpl index 9a8c6cf8..baccc94d 100644 --- a/chart/values.yaml.tmpl +++ b/chart/values.yaml.tmpl @@ -56,3 +56,4 @@ isAirgap: false userAgent: "" replicatedID: "" appID: "" +additionalMetricsEndpoint: "" diff --git a/cmd/replicated/api.go b/cmd/replicated/api.go index 7cfca323..708ef7db 100644 --- a/cmd/replicated/api.go +++ b/cmd/replicated/api.go @@ -57,23 +57,24 @@ func APICmd() *cobra.Command { } params := apiserver.APIServerParams{ - Context: cmd.Context(), - LicenseBytes: []byte(replicatedConfig.License), - IntegrationLicenseID: integrationLicenseID, - LicenseFields: replicatedConfig.LicenseFields, - AppName: replicatedConfig.AppName, - ChannelID: replicatedConfig.ChannelID, - ChannelName: replicatedConfig.ChannelName, - ChannelSequence: replicatedConfig.ChannelSequence, - ReleaseSequence: replicatedConfig.ReleaseSequence, - ReleaseCreatedAt: replicatedConfig.ReleaseCreatedAt, - ReleaseNotes: replicatedConfig.ReleaseNotes, - VersionLabel: replicatedConfig.VersionLabel, - ReplicatedAppEndpoint: replicatedConfig.ReplicatedAppEndpoint, - StatusInformers: replicatedConfig.StatusInformers, - ReplicatedID: replicatedConfig.ReplicatedID, - AppID: replicatedConfig.AppID, - Namespace: namespace, + Context: cmd.Context(), + LicenseBytes: []byte(replicatedConfig.License), + IntegrationLicenseID: integrationLicenseID, + LicenseFields: replicatedConfig.LicenseFields, + AppName: replicatedConfig.AppName, + ChannelID: replicatedConfig.ChannelID, + ChannelName: replicatedConfig.ChannelName, + ChannelSequence: replicatedConfig.ChannelSequence, + ReleaseSequence: replicatedConfig.ReleaseSequence, + ReleaseCreatedAt: replicatedConfig.ReleaseCreatedAt, + ReleaseNotes: replicatedConfig.ReleaseNotes, + VersionLabel: replicatedConfig.VersionLabel, + ReplicatedAppEndpoint: replicatedConfig.ReplicatedAppEndpoint, + StatusInformers: replicatedConfig.StatusInformers, + ReplicatedID: replicatedConfig.ReplicatedID, + AppID: replicatedConfig.AppID, + AdditionalMetricsEndpoint: replicatedConfig.AdditionalMetricsEndpoint, + Namespace: namespace, } apiserver.Start(params) diff --git a/pact/heartbeat_test.go b/pact/heartbeat_test.go index aacbb60a..de6346b9 100644 --- a/pact/heartbeat_test.go +++ b/pact/heartbeat_test.go @@ -57,6 +57,7 @@ func TestSendAppHeartbeat(t *testing.T) { State: appstatetypes.StateMissing, ResourceStates: []appstatetypes.ResourceState{}, }) + mockStore.EXPECT().GetAdditionalMetricsEndpoint().Return("") }, pactInteraction: func() { pact. @@ -108,6 +109,7 @@ func TestSendAppHeartbeat(t *testing.T) { State: appstatetypes.StateMissing, ResourceStates: []appstatetypes.ResourceState{}, }) + mockStore.EXPECT().GetAdditionalMetricsEndpoint().Return("") }, pactInteraction: func() { pact. @@ -159,6 +161,7 @@ func TestSendAppHeartbeat(t *testing.T) { State: appstatetypes.StateMissing, ResourceStates: []appstatetypes.ResourceState{}, }) + mockStore.EXPECT().GetAdditionalMetricsEndpoint().Return("") }, pactInteraction: func() { pact. @@ -210,6 +213,7 @@ func TestSendAppHeartbeat(t *testing.T) { State: appstatetypes.StateMissing, ResourceStates: []appstatetypes.ResourceState{}, }) + mockStore.EXPECT().GetAdditionalMetricsEndpoint().Return("") }, pactInteraction: func() { pact. diff --git a/pkg/apiserver/bootstrap.go b/pkg/apiserver/bootstrap.go index 0a12fad5..d3f9290f 100644 --- a/pkg/apiserver/bootstrap.go +++ b/pkg/apiserver/bootstrap.go @@ -91,20 +91,21 @@ func bootstrap(params APIServerParams) error { } storeOptions := store.InitInMemoryStoreOptions{ - License: verifiedLicense, - LicenseFields: params.LicenseFields, - AppName: params.AppName, - ChannelID: channelID, - ChannelName: channelName, - ChannelSequence: params.ChannelSequence, - ReleaseSequence: params.ReleaseSequence, - ReleaseCreatedAt: params.ReleaseCreatedAt, - ReleaseNotes: params.ReleaseNotes, - VersionLabel: params.VersionLabel, - ReplicatedAppEndpoint: params.ReplicatedAppEndpoint, - Namespace: params.Namespace, - ReplicatedID: replicatedID, - AppID: appID, + License: verifiedLicense, + LicenseFields: params.LicenseFields, + AppName: params.AppName, + ChannelID: channelID, + ChannelName: channelName, + ChannelSequence: params.ChannelSequence, + ReleaseSequence: params.ReleaseSequence, + ReleaseCreatedAt: params.ReleaseCreatedAt, + ReleaseNotes: params.ReleaseNotes, + VersionLabel: params.VersionLabel, + ReplicatedAppEndpoint: params.ReplicatedAppEndpoint, + Namespace: params.Namespace, + ReplicatedID: replicatedID, + AppID: appID, + AdditionalMetricsEndpoint: params.AdditionalMetricsEndpoint, } store.InitInMemory(storeOptions) @@ -120,7 +121,7 @@ func bootstrap(params APIServerParams) error { ChannelName: store.GetStore().GetChannelName(), ChannelSequence: store.GetStore().GetChannelSequence(), } - updates, err := upstream.GetUpdates(store.GetStore(), store.GetStore().GetLicense(), currentCursor) + updates, err := upstream.GetUpdates(store.GetStore(), clientset, store.GetStore().GetLicense(), currentCursor) if err != nil { return errors.Wrap(err, "failed to get updates") } diff --git a/pkg/apiserver/server.go b/pkg/apiserver/server.go index 86c28af9..5f040e7f 100644 --- a/pkg/apiserver/server.go +++ b/pkg/apiserver/server.go @@ -15,23 +15,24 @@ import ( ) type APIServerParams struct { - Context context.Context - LicenseBytes []byte - IntegrationLicenseID string - LicenseFields sdklicensetypes.LicenseFields - AppName string - ChannelID string - ChannelName string - ChannelSequence int64 - ReleaseSequence int64 - ReleaseCreatedAt string - ReleaseNotes string - VersionLabel string - ReplicatedAppEndpoint string - StatusInformers []appstatetypes.StatusInformerString - ReplicatedID string - AppID string - Namespace string + Context context.Context + LicenseBytes []byte + IntegrationLicenseID string + LicenseFields sdklicensetypes.LicenseFields + AppName string + ChannelID string + ChannelName string + ChannelSequence int64 + ReleaseSequence int64 + ReleaseCreatedAt string + ReleaseNotes string + VersionLabel string + ReplicatedAppEndpoint string + StatusInformers []appstatetypes.StatusInformerString + ReplicatedID string + AppID string + AdditionalMetricsEndpoint string + Namespace string } func Start(params APIServerParams) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 44afd8b8..6e878652 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -8,20 +8,21 @@ import ( ) type ReplicatedConfig struct { - License string `yaml:"license"` - LicenseFields sdklicensetypes.LicenseFields `yaml:"licenseFields"` - AppName string `yaml:"appName"` - ChannelID string `yaml:"channelID"` - ChannelName string `yaml:"channelName"` - ChannelSequence int64 `yaml:"channelSequence"` - ReleaseSequence int64 `yaml:"releaseSequence"` - ReleaseCreatedAt string `yaml:"releaseCreatedAt"` - ReleaseNotes string `yaml:"releaseNotes"` - VersionLabel string `yaml:"versionLabel"` - ReplicatedAppEndpoint string `yaml:"replicatedAppEndpoint"` - StatusInformers []appstatetypes.StatusInformerString `yaml:"statusInformers"` - ReplicatedID string `yaml:"replicatedID"` - AppID string `yaml:"appID"` + License string `yaml:"license"` + LicenseFields sdklicensetypes.LicenseFields `yaml:"licenseFields"` + AppName string `yaml:"appName"` + ChannelID string `yaml:"channelID"` + ChannelName string `yaml:"channelName"` + ChannelSequence int64 `yaml:"channelSequence"` + ReleaseSequence int64 `yaml:"releaseSequence"` + ReleaseCreatedAt string `yaml:"releaseCreatedAt"` + ReleaseNotes string `yaml:"releaseNotes"` + VersionLabel string `yaml:"versionLabel"` + ReplicatedAppEndpoint string `yaml:"replicatedAppEndpoint"` + StatusInformers []appstatetypes.StatusInformerString `yaml:"statusInformers"` + ReplicatedID string `yaml:"replicatedID"` + AppID string `yaml:"appID"` + AdditionalMetricsEndpoint string `yaml:"additionalMetricsEndpoint"` } func ParseReplicatedConfig(config []byte) (*ReplicatedConfig, error) { diff --git a/pkg/handlers/app.go b/pkg/handlers/app.go index 1cfde832..c942b929 100644 --- a/pkg/handlers/app.go +++ b/pkg/handlers/app.go @@ -170,7 +170,7 @@ func GetAppUpdates(w http.ResponseWriter, r *http.Request) { ChannelName: store.GetStore().GetChannelName(), ChannelSequence: store.GetStore().GetChannelSequence(), } - us, err := upstream.GetUpdates(store.GetStore(), license, currentCursor) + us, err := upstream.GetUpdates(store.GetStore(), clientset, license, currentCursor) if err != nil { logger.Error(errors.Wrap(err, "failed to get updates")) JSONCached(w, http.StatusOK, updates) diff --git a/pkg/handlers/custom_metrics.go b/pkg/handlers/custom_metrics.go index 20a58789..6b43fd8d 100644 --- a/pkg/handlers/custom_metrics.go +++ b/pkg/handlers/custom_metrics.go @@ -6,6 +6,7 @@ import ( "reflect" "github.com/pkg/errors" + "github.com/replicatedhq/replicated-sdk/pkg/k8sutil" "github.com/replicatedhq/replicated-sdk/pkg/logger" "github.com/replicatedhq/replicated-sdk/pkg/metrics" "github.com/replicatedhq/replicated-sdk/pkg/store" @@ -39,7 +40,14 @@ func SendCustomApplicationMetrics(w http.ResponseWriter, r *http.Request) { return } - err := metrics.SendApplicationMetricsData(store.GetStore(), license, request.Data) + clientset, err := k8sutil.GetClientset() + if err != nil { + logger.Error(errors.Wrap(err, "failed to get clientset")) + w.WriteHeader(http.StatusInternalServerError) + return + } + + err = metrics.SendApplicationMetricsData(store.GetStore(), clientset, license, request.Data) if err != nil { logger.Error(errors.Wrap(err, "set application data")) w.WriteHeader(http.StatusBadRequest) diff --git a/pkg/heartbeat/app.go b/pkg/heartbeat/app.go index 28a484de..fa32a60e 100644 --- a/pkg/heartbeat/app.go +++ b/pkg/heartbeat/app.go @@ -27,7 +27,7 @@ func SendAppHeartbeat(clientset kubernetes.Interface, sdkStore store.Store) erro return nil } - heartbeatInfo := GetHeartbeatInfo(sdkStore) + heartbeatInfo := GetHeartbeatInfo(sdkStore, clientset) marshalledRS, err := json.Marshal(heartbeatInfo.ResourceStates) if err != nil { @@ -63,7 +63,7 @@ func SendAppHeartbeat(clientset kubernetes.Interface, sdkStore store.Store) erro return nil } -func GetHeartbeatInfo(sdkStore store.Store) *types.HeartbeatInfo { +func GetHeartbeatInfo(sdkStore store.Store, clientset kubernetes.Interface) *types.HeartbeatInfo { r := types.HeartbeatInfo{ ClusterID: sdkStore.GetReplicatedID(), InstanceID: sdkStore.GetAppID(), @@ -74,10 +74,7 @@ func GetHeartbeatInfo(sdkStore store.Store) *types.HeartbeatInfo { ResourceStates: sdkStore.GetAppStatus().ResourceStates, } - clientset, err := k8sutil.GetClientset() - if err != nil { - logger.Debugf("failed to get clientset: %v", err.Error()) - } else { + if clientset != nil { k8sVersion, err := k8sutil.GetK8sVersion(clientset) if err != nil { logger.Debugf("failed to get k8s version: %v", err.Error()) @@ -90,5 +87,40 @@ func GetHeartbeatInfo(sdkStore store.Store) *types.HeartbeatInfo { } } + if sdkStore.GetAdditionalMetricsEndpoint() != "" { + additionalMetrics, err := getAdditionalMetrics(sdkStore.GetAdditionalMetricsEndpoint(), sdkStore.GetLicense().Spec.LicenseID) + if err != nil { + logger.Errorf("failed to get additional metrics: %v", err.Error()) + } else { + r.AdditionalMetrics = additionalMetrics + } + } + return &r } + +func getAdditionalMetrics(endpoint string, licenseID string) (types.AdditionalMetrics, error) { + req, err := util.NewRequest("GET", endpoint, nil) + if err != nil { + return nil, errors.Wrap(err, "failed to create http request") + } + + req.Header.Set("Authorization", licenseID) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, errors.Wrap(err, "failed to get request") + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, errors.Errorf("Unexpected status code %d", resp.StatusCode) + } + + var additionalMetrics types.AdditionalMetrics + if err := json.NewDecoder(resp.Body).Decode(&additionalMetrics); err != nil { + return nil, errors.Wrap(err, "failed to decode response body") + } + + return additionalMetrics, nil +} diff --git a/pkg/heartbeat/app_test.go b/pkg/heartbeat/app_test.go new file mode 100644 index 00000000..192f4219 --- /dev/null +++ b/pkg/heartbeat/app_test.go @@ -0,0 +1,204 @@ +package heartbeat + +import ( + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + appstatetypes "github.com/replicatedhq/replicated-sdk/pkg/appstate/types" + "github.com/replicatedhq/replicated-sdk/pkg/heartbeat/types" + "github.com/replicatedhq/replicated-sdk/pkg/store" + mock_store "github.com/replicatedhq/replicated-sdk/pkg/store/mock" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/version" + discoveryfake "k8s.io/client-go/discovery/fake" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" +) + +func TestGetHeartbeatInfo(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := mock_store.NewMockStore(ctrl) + + mockRouter := mux.NewRouter() + mockServer := httptest.NewServer(mockRouter) + defer mockServer.Close() + mockRouter.Methods("GET").Path("/metrics").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"X-Replicated-Metric": "test"}`)) + w.WriteHeader(http.StatusOK) + }) + + type args struct { + sdkStore store.Store + clientset kubernetes.Interface + } + tests := []struct { + name string + args args + mockStoreExpectations func() + want *types.HeartbeatInfo + }{ + { + name: "with no k8s client or metrics endpoint", + args: args{ + sdkStore: mockStore, + clientset: nil, + }, + mockStoreExpectations: func() { + mockStore.EXPECT().GetReplicatedID().Return("sdk-heartbeat-cluster-id") + mockStore.EXPECT().GetAppID().Return("sdk-heartbeat-app") + mockStore.EXPECT().GetChannelID().Return("sdk-heartbeat-app-nightly") + mockStore.EXPECT().GetChannelName().Return("Nightly") + mockStore.EXPECT().GetChannelSequence().Return(int64(1)) + mockStore.EXPECT().GetAppStatus().Times(2).Return(appstatetypes.AppStatus{ + AppSlug: "sdk-heartbeat-app", + Sequence: 1, + State: appstatetypes.StateMissing, + ResourceStates: []appstatetypes.ResourceState{}, + }) + mockStore.EXPECT().GetAdditionalMetricsEndpoint().Return("") + }, + want: &types.HeartbeatInfo{ + ClusterID: "sdk-heartbeat-cluster-id", + InstanceID: "sdk-heartbeat-app", + ChannelID: "sdk-heartbeat-app-nightly", + ChannelName: "Nightly", + ChannelSequence: 1, + AppStatus: string(appstatetypes.StateMissing), + ResourceStates: []appstatetypes.ResourceState{}, + }, + }, + { + name: "with k8s client and no metrics endpoint", + args: args{ + sdkStore: mockStore, + clientset: mockClientset("v1.26.0+k3s"), + }, + mockStoreExpectations: func() { + mockStore.EXPECT().GetReplicatedID().Return("sdk-heartbeat-cluster-id") + mockStore.EXPECT().GetAppID().Return("sdk-heartbeat-app") + mockStore.EXPECT().GetChannelID().Return("sdk-heartbeat-app-nightly") + mockStore.EXPECT().GetChannelName().Return("Nightly") + mockStore.EXPECT().GetChannelSequence().Return(int64(1)) + mockStore.EXPECT().GetAppStatus().Times(2).Return(appstatetypes.AppStatus{ + AppSlug: "sdk-heartbeat-app", + Sequence: 1, + State: appstatetypes.StateMissing, + ResourceStates: []appstatetypes.ResourceState{}, + }) + mockStore.EXPECT().GetAdditionalMetricsEndpoint().Return("") + }, + want: &types.HeartbeatInfo{ + ClusterID: "sdk-heartbeat-cluster-id", + InstanceID: "sdk-heartbeat-app", + ChannelID: "sdk-heartbeat-app-nightly", + ChannelName: "Nightly", + ChannelSequence: 1, + AppStatus: string(appstatetypes.StateMissing), + ResourceStates: []appstatetypes.ResourceState{}, + K8sVersion: "v1.26.0+k3s", + K8sDistribution: "k3s", + }, + }, + { + name: "with no k8s client with metrics endpoint", + args: args{ + sdkStore: mockStore, + clientset: nil, + }, + mockStoreExpectations: func() { + mockStore.EXPECT().GetReplicatedID().Return("sdk-heartbeat-cluster-id") + mockStore.EXPECT().GetAppID().Return("sdk-heartbeat-app") + mockStore.EXPECT().GetChannelID().Return("sdk-heartbeat-app-nightly") + mockStore.EXPECT().GetChannelName().Return("Nightly") + mockStore.EXPECT().GetChannelSequence().Return(int64(1)) + mockStore.EXPECT().GetAppStatus().Times(2).Return(appstatetypes.AppStatus{ + AppSlug: "sdk-heartbeat-app", + Sequence: 1, + State: appstatetypes.StateMissing, + ResourceStates: []appstatetypes.ResourceState{}, + }) + mockStore.EXPECT().GetAdditionalMetricsEndpoint().Times(2).Return(fmt.Sprintf("%s/metrics", mockServer.URL)) + mockStore.EXPECT().GetLicense().Return(&v1beta1.License{ + Spec: v1beta1.LicenseSpec{ + LicenseID: "sdk-heartbeat-customer-0-license", + }, + }) + }, + want: &types.HeartbeatInfo{ + ClusterID: "sdk-heartbeat-cluster-id", + InstanceID: "sdk-heartbeat-app", + ChannelID: "sdk-heartbeat-app-nightly", + ChannelName: "Nightly", + ChannelSequence: 1, + AppStatus: string(appstatetypes.StateMissing), + ResourceStates: []appstatetypes.ResourceState{}, + AdditionalMetrics: types.AdditionalMetrics{ + "X-Replicated-Metric": "test", + }, + }, + }, + { + name: "with k8s client and metrics endpoint", + args: args{ + sdkStore: mockStore, + clientset: mockClientset("v1.26.0+k3s"), + }, + mockStoreExpectations: func() { + mockStore.EXPECT().GetReplicatedID().Return("sdk-heartbeat-cluster-id") + mockStore.EXPECT().GetAppID().Return("sdk-heartbeat-app") + mockStore.EXPECT().GetChannelID().Return("sdk-heartbeat-app-nightly") + mockStore.EXPECT().GetChannelName().Return("Nightly") + mockStore.EXPECT().GetChannelSequence().Return(int64(1)) + mockStore.EXPECT().GetAppStatus().Times(2).Return(appstatetypes.AppStatus{ + AppSlug: "sdk-heartbeat-app", + Sequence: 1, + State: appstatetypes.StateMissing, + ResourceStates: []appstatetypes.ResourceState{}, + }) + mockStore.EXPECT().GetAdditionalMetricsEndpoint().Times(2).Return(fmt.Sprintf("%s/metrics", mockServer.URL)) + mockStore.EXPECT().GetLicense().Return(&v1beta1.License{ + Spec: v1beta1.LicenseSpec{ + LicenseID: "sdk-heartbeat-customer-0-license", + }, + }) + }, + want: &types.HeartbeatInfo{ + ClusterID: "sdk-heartbeat-cluster-id", + InstanceID: "sdk-heartbeat-app", + ChannelID: "sdk-heartbeat-app-nightly", + ChannelName: "Nightly", + ChannelSequence: 1, + AppStatus: string(appstatetypes.StateMissing), + ResourceStates: []appstatetypes.ResourceState{}, + K8sVersion: "v1.26.0+k3s", + K8sDistribution: "k3s", + AdditionalMetrics: types.AdditionalMetrics{ + "X-Replicated-Metric": "test", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockStoreExpectations() + if got := GetHeartbeatInfo(tt.args.sdkStore, tt.args.clientset); !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetHeartbeatInfo() = %v, want %v", got, tt.want) + } + }) + } +} + +func mockClientset(gitVersion string, objects ...runtime.Object) kubernetes.Interface { + clientset := fake.NewSimpleClientset(objects...) + clientset.Discovery().(*discoveryfake.FakeDiscovery).FakedServerVersion = &version.Info{ + GitVersion: gitVersion, + } + return clientset +} diff --git a/pkg/heartbeat/types/types.go b/pkg/heartbeat/types/types.go index 7da3b302..fa0eca33 100644 --- a/pkg/heartbeat/types/types.go +++ b/pkg/heartbeat/types/types.go @@ -24,16 +24,17 @@ const ( ) type HeartbeatInfo struct { - InstanceID string `json:"instance_id"` - ClusterID string `json:"cluster_id"` - ChannelID string `json:"channel_id"` - ChannelName string `json:"channel_name"` - ChannelSequence int64 `json:"channel_sequence"` - ReleaseSequence int64 `json:"release_sequence"` - AppStatus string `json:"app_status"` - ResourceStates appstatetypes.ResourceStates `json:"resource_states"` - K8sVersion string `json:"k8s_version"` - K8sDistribution string `json:"k8s_distribution"` + InstanceID string `json:"instance_id"` + ClusterID string `json:"cluster_id"` + ChannelID string `json:"channel_id"` + ChannelName string `json:"channel_name"` + ChannelSequence int64 `json:"channel_sequence"` + ReleaseSequence int64 `json:"release_sequence"` + AppStatus string `json:"app_status"` + ResourceStates appstatetypes.ResourceStates `json:"resource_states"` + K8sVersion string `json:"k8s_version"` + K8sDistribution string `json:"k8s_distribution"` + AdditionalMetrics AdditionalMetrics `json:"additional_metrics"` } func (d Distribution) String() string { @@ -67,3 +68,5 @@ func (d Distribution) String() string { } return "unknown" } + +type AdditionalMetrics map[string]string diff --git a/pkg/heartbeat/util.go b/pkg/heartbeat/util.go index cf328cf9..cc693b81 100644 --- a/pkg/heartbeat/util.go +++ b/pkg/heartbeat/util.go @@ -36,6 +36,13 @@ func InjectHeartbeatInfoHeaders(req *http.Request, heartbeatInfo *types.Heartbea if heartbeatInfo.K8sDistribution != "" { req.Header.Set("X-Replicated-K8sDistribution", heartbeatInfo.K8sDistribution) } + + for k, v := range heartbeatInfo.AdditionalMetrics { + if req.Header.Get(k) != "" { + continue + } + req.Header.Set(k, v) + } } func canReport(clientset kubernetes.Interface, namespace string, license *kotsv1beta1.License) (bool, error) { diff --git a/pkg/heartbeat/util_test.go b/pkg/heartbeat/util_test.go index b600d760..3399f4ec 100644 --- a/pkg/heartbeat/util_test.go +++ b/pkg/heartbeat/util_test.go @@ -1,8 +1,10 @@ package heartbeat import ( + "net/http" "testing" + "github.com/replicatedhq/replicated-sdk/pkg/heartbeat/types" "github.com/replicatedhq/replicated-sdk/pkg/k8sutil" "github.com/replicatedhq/replicated-sdk/pkg/util" "k8s.io/client-go/kubernetes/fake" @@ -124,3 +126,60 @@ func TestCanReport(t *testing.T) { }) } } + +func TestInjectHeartbeatInfoHeaders(t *testing.T) { + type args struct { + req *http.Request + heartbeatInfo *types.HeartbeatInfo + } + tests := []struct { + name string + args args + wantHeaders map[string]string + }{ + { + name: "no heartbeat info", + args: args{ + req: &http.Request{ + Header: map[string][]string{ + "X-Replicated-Test": {"foo"}, + }, + }, + heartbeatInfo: nil, + }, + wantHeaders: map[string]string{ + "X-Replicated-Test": "foo", + }, + }, + { + name: "additional metrics", + args: args{ + req: &http.Request{ + Header: map[string][]string{ + "X-Replicated-Test": {"foo"}, + }, + }, + heartbeatInfo: &types.HeartbeatInfo{ + AdditionalMetrics: types.AdditionalMetrics{ + "X-Replicated-Test": "bar", + "X-Replicated-TestAdditional": "baz", + }, + }, + }, + wantHeaders: map[string]string{ + "X-Replicated-Test": "foo", + "X-Replicated-TestAdditional": "baz", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + InjectHeartbeatInfoHeaders(tt.args.req, tt.args.heartbeatInfo) + for k, v := range tt.wantHeaders { + if tt.args.req.Header.Get(k) != v { + t.Errorf("InjectHeartbeatInfoHeaders() = got %v: %v, want %v: %v", k, tt.args.req.Header.Get(k), k, v) + } + } + }) + } +} diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index ec4397aa..d0062e01 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -13,9 +13,10 @@ import ( "github.com/replicatedhq/replicated-sdk/pkg/heartbeat" "github.com/replicatedhq/replicated-sdk/pkg/store" "github.com/replicatedhq/replicated-sdk/pkg/util" + "k8s.io/client-go/kubernetes" ) -func SendApplicationMetricsData(sdkStore store.Store, license *kotsv1beta1.License, data map[string]interface{}) error { +func SendApplicationMetricsData(sdkStore store.Store, clientset kubernetes.Interface, license *kotsv1beta1.License, data map[string]interface{}) error { endpoint := sdkStore.GetReplicatedAppEndpoint() if endpoint == "" { endpoint = license.Spec.Endpoint @@ -52,7 +53,7 @@ func SendApplicationMetricsData(sdkStore store.Store, license *kotsv1beta1.Licen req.SetBasicAuth(license.Spec.LicenseID, license.Spec.LicenseID) req.Header.Set("Content-Type", "application/json") - heartbeatInfo := heartbeat.GetHeartbeatInfo(sdkStore) + heartbeatInfo := heartbeat.GetHeartbeatInfo(sdkStore, clientset) heartbeat.InjectHeartbeatInfoHeaders(req, heartbeatInfo) resp, err := http.DefaultClient.Do(req) diff --git a/pkg/store/memory_store.go b/pkg/store/memory_store.go index 6b0c8440..27757404 100644 --- a/pkg/store/memory_store.go +++ b/pkg/store/memory_store.go @@ -8,59 +8,62 @@ import ( ) type InMemoryStore struct { - replicatedID string - appID string - license *kotsv1beta1.License - licenseFields sdklicensetypes.LicenseFields - appSlug string - appName string - channelID string - channelName string - channelSequence int64 - releaseSequence int64 - releaseCreatedAt string - releaseNotes string - versionLabel string - replicatedAppEndpoint string - namespace string - appStatus appstatetypes.AppStatus - updates []upstreamtypes.ChannelRelease + replicatedID string + appID string + license *kotsv1beta1.License + licenseFields sdklicensetypes.LicenseFields + appSlug string + appName string + channelID string + channelName string + channelSequence int64 + releaseSequence int64 + releaseCreatedAt string + releaseNotes string + versionLabel string + replicatedAppEndpoint string + additionalMetricsEndpoint string + namespace string + appStatus appstatetypes.AppStatus + updates []upstreamtypes.ChannelRelease } type InitInMemoryStoreOptions struct { - ReplicatedID string - AppID string - License *kotsv1beta1.License - LicenseFields sdklicensetypes.LicenseFields - AppName string - ChannelID string - ChannelName string - ChannelSequence int64 - ReleaseSequence int64 - ReleaseCreatedAt string - ReleaseNotes string - VersionLabel string - ReplicatedAppEndpoint string - Namespace string + ReplicatedID string + AppID string + License *kotsv1beta1.License + LicenseFields sdklicensetypes.LicenseFields + AppName string + ChannelID string + ChannelName string + ChannelSequence int64 + ReleaseSequence int64 + ReleaseCreatedAt string + ReleaseNotes string + VersionLabel string + ReplicatedAppEndpoint string + AdditionalMetricsEndpoint string + Namespace string } func InitInMemory(options InitInMemoryStoreOptions) { SetStore(&InMemoryStore{ - replicatedID: options.ReplicatedID, - appID: options.AppID, - appSlug: options.License.Spec.AppSlug, - license: options.License, - licenseFields: options.LicenseFields, - appName: options.AppName, - channelID: options.ChannelID, - channelName: options.ChannelName, - channelSequence: options.ChannelSequence, - releaseSequence: options.ReleaseSequence, - releaseCreatedAt: options.ReleaseCreatedAt, - releaseNotes: options.ReleaseNotes, - versionLabel: options.VersionLabel, - replicatedAppEndpoint: options.ReplicatedAppEndpoint, - namespace: options.Namespace, + replicatedID: options.ReplicatedID, + appID: options.AppID, + appSlug: options.License.Spec.AppSlug, + license: options.License, + licenseFields: options.LicenseFields, + appName: options.AppName, + channelID: options.ChannelID, + channelName: options.ChannelName, + channelSequence: options.ChannelSequence, + releaseSequence: options.ReleaseSequence, + releaseCreatedAt: options.ReleaseCreatedAt, + releaseNotes: options.ReleaseNotes, + versionLabel: options.VersionLabel, + replicatedAppEndpoint: options.ReplicatedAppEndpoint, + additionalMetricsEndpoint: options.AdditionalMetricsEndpoint, + namespace: options.Namespace, }) } @@ -142,6 +145,10 @@ func (s *InMemoryStore) GetReplicatedAppEndpoint() string { return s.replicatedAppEndpoint } +func (s *InMemoryStore) GetAdditionalMetricsEndpoint() string { + return s.additionalMetricsEndpoint +} + func (s *InMemoryStore) GetNamespace() string { return s.namespace } diff --git a/pkg/store/mock/mock_store.go b/pkg/store/mock/mock_store.go index 28cc00cc..60c8f676 100644 --- a/pkg/store/mock/mock_store.go +++ b/pkg/store/mock/mock_store.go @@ -37,6 +37,20 @@ func (m *MockStore) EXPECT() *MockStoreMockRecorder { return m.recorder } +// GetAdditionalMetricsEndpoint mocks base method. +func (m *MockStore) GetAdditionalMetricsEndpoint() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAdditionalMetricsEndpoint") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetAdditionalMetricsEndpoint indicates an expected call of GetAdditionalMetricsEndpoint. +func (mr *MockStoreMockRecorder) GetAdditionalMetricsEndpoint() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAdditionalMetricsEndpoint", reflect.TypeOf((*MockStore)(nil).GetAdditionalMetricsEndpoint)) +} + // GetAppID mocks base method. func (m *MockStore) GetAppID() string { m.ctrl.T.Helper() @@ -261,20 +275,6 @@ func (mr *MockStoreMockRecorder) GetUpdates() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUpdates", reflect.TypeOf((*MockStore)(nil).GetUpdates)) } -// GetUserAgent mocks base method. -func (m *MockStore) GetUserAgent() string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetUserAgent") - ret0, _ := ret[0].(string) - return ret0 -} - -// GetUserAgent indicates an expected call of GetUserAgent. -func (mr *MockStoreMockRecorder) GetUserAgent() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserAgent", reflect.TypeOf((*MockStore)(nil).GetUserAgent)) -} - // GetVersionLabel mocks base method. func (m *MockStore) GetVersionLabel() string { m.ctrl.T.Helper() diff --git a/pkg/store/store_interface.go b/pkg/store/store_interface.go index 88561742..41000440 100644 --- a/pkg/store/store_interface.go +++ b/pkg/store/store_interface.go @@ -31,6 +31,7 @@ type Store interface { GetReleaseNotes() string GetVersionLabel() string GetReplicatedAppEndpoint() string + GetAdditionalMetricsEndpoint() string GetNamespace() string GetAppStatus() appstatetypes.AppStatus SetAppStatus(status appstatetypes.AppStatus) diff --git a/pkg/upstream/replicated.go b/pkg/upstream/replicated.go index 1b02280b..ce28c3a9 100644 --- a/pkg/upstream/replicated.go +++ b/pkg/upstream/replicated.go @@ -15,9 +15,10 @@ import ( "github.com/replicatedhq/replicated-sdk/pkg/store" types "github.com/replicatedhq/replicated-sdk/pkg/upstream/types" "github.com/replicatedhq/replicated-sdk/pkg/util" + "k8s.io/client-go/kubernetes" ) -func GetUpdates(sdkStore store.Store, license *kotsv1beta1.License, currentCursor types.ReplicatedCursor) ([]types.ChannelRelease, error) { +func GetUpdates(sdkStore store.Store, clientset kubernetes.Interface, license *kotsv1beta1.License, currentCursor types.ReplicatedCursor) ([]types.ChannelRelease, error) { endpoint := sdkStore.GetReplicatedAppEndpoint() if endpoint == "" { endpoint = license.Spec.Endpoint @@ -49,7 +50,7 @@ func GetUpdates(sdkStore store.Store, license *kotsv1beta1.License, currentCurso url := fmt.Sprintf("%s://%s/release/%s/pending?%s", u.Scheme, hostname, license.Spec.AppSlug, urlValues.Encode()) // build the request body - heartbeatInfo := heartbeat.GetHeartbeatInfo(sdkStore) + heartbeatInfo := heartbeat.GetHeartbeatInfo(sdkStore, clientset) marshalledRS, err := json.Marshal(heartbeatInfo.ResourceStates) if err != nil {