diff --git a/.github/actions/validate-endpoints/action.yml b/.github/actions/validate-endpoints/action.yml index 2f03008e..96fc6a30 100644 --- a/.github/actions/validate-endpoints/action.yml +++ b/.github/actions/validate-endpoints/action.yml @@ -150,3 +150,32 @@ runs: exit 1 fi fi + + - name: Validate /app/custom-metrics endpoint + shell: bash + run: | + for i in {1..5} + do + string_key=$(shuf -er -n10 {A..Z} {a..z} {0..9} | tr -d '\n') + integer_key=$(shuf -i 1-100 -n 1 | tr -d '\n') + bool_key=true + body='{"data":{"string_key":"'$string_key'","integer_key":'$integer_key',"bool_key":'$bool_key'}}' + curl -XPOST -s --fail --show-error localhost:8888/api/v1/app/custom-metrics -H 'Content-Type: application/json' -d $body + done + + if [ "${{ inputs.is-airgap }}" == "false" ]; then + exit 0 + fi + + if ! kubectl -n "${{ inputs.namespace }}" get secret/replicated-custom-app-metrics-report; then + echo "Did not create replicated-custom-app-metrics-report secret" + exit 1 + fi + + report=$(kubectl -n "${{ inputs.namespace }}" get secret replicated-custom-app-metrics-report -ojsonpath='{.data.report}' | base64 -d | base64 -d | gunzip | jq .) + + numOfEvents=$(echo "$report" | jq '.events | length' | tr -d '\n') + if [ "$numOfEvents" != "5" ]; then + echo "Expected 5 events, but found $numOfEvents" + exit 1 + fi diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 2d132730..3800afdb 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -471,7 +471,7 @@ jobs: curl -fsLO "https://github.com/replicatedhq/troubleshoot/releases/download/${RELEASE}/support-bundle_linux_amd64.tar.gz" tar xzf support-bundle_linux_amd64.tar.gz - - name: Install via Helm as subchart in production mode + - name: Install via Helm as subchart in production airgap mode run: | helm install test-chart oci://registry.replicated.com/$APP_SLUG/$CHANNEL_SLUG/test-chart --set replicated.integration.enabled=false --set replicated.isAirgap=true --wait --timeout 2m @@ -501,6 +501,10 @@ jobs: echo "Did not find replicated-instance-report in support bundle" exit 1 fi + if ! ls support-bundle-*/secrets/*/replicated-custom-app-metrics-report/report.json; then + echo "Did not find replicated-custom-app-metrics-report in support bundle" + exit 1 + fi rm -rf support-bundle-* - name: Uninstall test-chart via Helm @@ -517,7 +521,7 @@ jobs: sleep 1 done - - name: Install via kubectl as subchart in production mode + - name: Install via kubectl as subchart in production airgap mode run: | helm template test-chart oci://registry.replicated.com/$APP_SLUG/$CHANNEL_SLUG/test-chart --set replicated.integration.enabled=false --set replicated.isAirgap=true | kubectl apply -f - kubectl rollout status deployment test-chart --timeout=2m @@ -550,6 +554,10 @@ jobs: echo "Did not find replicated-instance-report in support bundle" exit 1 fi + if ! ls support-bundle-*/secrets/*/replicated-custom-app-metrics-report/report.json; then + echo "Did not find replicated-custom-app-metrics-report in support bundle" + exit 1 + fi rm -rf support-bundle-* - name: Uninstall test-chart via kubectl diff --git a/chart/templates/replicated-role.yaml b/chart/templates/replicated-role.yaml index df0f711f..ab97bc5f 100644 --- a/chart/templates/replicated-role.yaml +++ b/chart/templates/replicated-role.yaml @@ -30,4 +30,5 @@ rules: resourceNames: - {{ include "replicated.secretName" . }} - replicated-instance-report + - replicated-custom-app-metrics-report {{ end }} \ No newline at end of file diff --git a/chart/templates/replicated-supportbundle.yaml b/chart/templates/replicated-supportbundle.yaml index 96b9c4f9..c801a099 100644 --- a/chart/templates/replicated-supportbundle.yaml +++ b/chart/templates/replicated-supportbundle.yaml @@ -44,6 +44,11 @@ stringData: name: replicated-instance-report includeValue: true key: report + - secret: + namespace: {{ include "replicated.namespace" . }} + name: replicated-custom-app-metrics-report + includeValue: true + key: report analyzers: - jsonCompare: checkName: Replicated SDK App Status diff --git a/pact/custom_metrics_test.go b/pact/custom_metrics_test.go index ea297cc6..a6d034a7 100644 --- a/pact/custom_metrics_test.go +++ b/pact/custom_metrics_test.go @@ -28,7 +28,7 @@ func TestSendCustomAppMetrics(t *testing.T) { "key4_numeric_string": "1.6", }, } - customMetricsData, _ := json.Marshal(data) + customAppMetricsData, _ := json.Marshal(data) license := &v1beta1.License{ Spec: v1beta1.LicenseSpec{ LicenseID: "sdk-license-customer-0-license", @@ -40,14 +40,14 @@ func TestSendCustomAppMetrics(t *testing.T) { clientWriter := httptest.NewRecorder() clientRequest := &http.Request{ - Body: io.NopCloser(bytes.NewBuffer(customMetricsData)), + Body: io.NopCloser(bytes.NewBuffer(customAppMetricsData)), } pactInteraction := func() { pact. AddInteraction(). - Given("Send valid custom metrics"). - UponReceiving("A request to send custom metrics"). + Given("Send valid custom app metrics"). + UponReceiving("A request to send custom app metrics"). WithRequest(dsl.Request{ Method: http.MethodPost, Headers: dsl.MapMatcher{ @@ -63,7 +63,7 @@ func TestSendCustomAppMetrics(t *testing.T) { Status: http.StatusOK, }) } - t.Run("Send valid custom metrics", func(t *testing.T) { + t.Run("Send valid custom app metrics", func(t *testing.T) { pactInteraction() storeOptions := store.InitInMemoryStoreOptions{ diff --git a/pact/heartbeat_test.go b/pact/instance_test.go similarity index 61% rename from pact/heartbeat_test.go rename to pact/instance_test.go index aacbb60a..8ce9c0c6 100644 --- a/pact/heartbeat_test.go +++ b/pact/instance_test.go @@ -10,25 +10,25 @@ import ( "github.com/pact-foundation/pact-go/dsl" "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" appstatetypes "github.com/replicatedhq/replicated-sdk/pkg/appstate/types" - "github.com/replicatedhq/replicated-sdk/pkg/heartbeat" "github.com/replicatedhq/replicated-sdk/pkg/k8sutil" + "github.com/replicatedhq/replicated-sdk/pkg/report" mock_store "github.com/replicatedhq/replicated-sdk/pkg/store/mock" "github.com/replicatedhq/replicated-sdk/pkg/util" "k8s.io/client-go/kubernetes/fake" ) -func TestSendAppHeartbeat(t *testing.T) { +func TestSendInstanceData(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockStore := mock_store.NewMockStore(ctrl) clientset := fake.NewSimpleClientset( - k8sutil.CreateTestDeployment(util.GetReplicatedDeploymentName(), "sdk-heartbeat-namespace", "1", map[string]string{"app": "sdk-heartbeat-app"}), - k8sutil.CreateTestReplicaSet("sdk-heartbeat-replicaset", "sdk-heartbeat-namespace", "1"), - k8sutil.CreateTestPod("sdk-heartbeat-pod", "sdk-heartbeat-namespace", "sdk-heartbeat-replicaset", map[string]string{"app": "sdk-heartbeat-app"}), + k8sutil.CreateTestDeployment(util.GetReplicatedDeploymentName(), "replicated-sdk-instance-namespace", "1", map[string]string{"app": "replicated-sdk-instance-app"}), + k8sutil.CreateTestReplicaSet("replicated-sdk-instance-replicaset", "replicated-sdk-instance-namespace", "1"), + k8sutil.CreateTestPod("replicated-sdk-instance-pod", "replicated-sdk-instance-namespace", "replicated-sdk-instance-replicaset", map[string]string{"app": "replicated-sdk-instance-app"}), ) - t.Setenv("REPLICATED_POD_NAME", "sdk-heartbeat-pod") + t.Setenv("REPLICATED_POD_NAME", "replicated-sdk-instance-pod") tests := []struct { name string @@ -37,22 +37,22 @@ func TestSendAppHeartbeat(t *testing.T) { wantErr bool }{ { - name: "successful heartbeat", + name: "successful instance data request", mockStoreExpectations: func() { mockStore.EXPECT().GetLicense().Return(&v1beta1.License{ Spec: v1beta1.LicenseSpec{ - LicenseID: "sdk-heartbeat-customer-0-license", + LicenseID: "replicated-sdk-instance-customer-0-license", Endpoint: fmt.Sprintf("http://%s:%d", pact.Host, pact.Server.Port), }, }) - mockStore.EXPECT().GetNamespace().Return("sdk-heartbeat-namespace") - 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().GetNamespace().Return("replicated-sdk-instance-namespace") + mockStore.EXPECT().GetReplicatedID().Return("replicated-sdk-instance-cluster-id") + mockStore.EXPECT().GetAppID().Return("replicated-sdk-instance-app") + mockStore.EXPECT().GetChannelID().Return("replicated-sdk-instance-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", + AppSlug: "replicated-sdk-instance-app", Sequence: 1, State: appstatetypes.StateMissing, ResourceStates: []appstatetypes.ResourceState{}, @@ -62,18 +62,18 @@ func TestSendAppHeartbeat(t *testing.T) { pact. AddInteraction(). Given("License exists and is not expired"). - UponReceiving("A heartbeat from the Replicated SDK"). + UponReceiving("Instance data from the Replicated SDK"). WithRequest(dsl.Request{ Method: http.MethodPost, Headers: dsl.MapMatcher{ "User-Agent": dsl.String("Replicated-SDK/v0.0.0-unknown"), - "Authorization": dsl.String(fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", "sdk-heartbeat-customer-0-license", "sdk-heartbeat-customer-0-license"))))), + "Authorization": dsl.String(fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", "replicated-sdk-instance-customer-0-license", "replicated-sdk-instance-customer-0-license"))))), "Content-Type": dsl.String("application/json"), "X-Replicated-K8sVersion": dsl.Like("v1.25.3"), "X-Replicated-AppStatus": dsl.String("missing"), - "X-Replicated-ClusterID": dsl.String("sdk-heartbeat-cluster-id"), - "X-Replicated-InstanceID": dsl.String("sdk-heartbeat-app"), - "X-Replicated-DownstreamChannelID": dsl.String("sdk-heartbeat-app-nightly"), + "X-Replicated-ClusterID": dsl.String("replicated-sdk-instance-cluster-id"), + "X-Replicated-InstanceID": dsl.String("replicated-sdk-instance-app"), + "X-Replicated-DownstreamChannelID": dsl.String("replicated-sdk-instance-app-nightly"), "X-Replicated-DownstreamChannelSequence": dsl.String("1"), }, Path: dsl.String("/kots_metrics/license_instance/info"), @@ -88,22 +88,22 @@ func TestSendAppHeartbeat(t *testing.T) { wantErr: false, }, { - name: "expired license heartbeat should return error", + name: "expired license should return error", mockStoreExpectations: func() { mockStore.EXPECT().GetLicense().Return(&v1beta1.License{ Spec: v1beta1.LicenseSpec{ - LicenseID: "sdk-heartbeat-customer-2-license", + LicenseID: "replicated-sdk-instance-customer-2-license", Endpoint: fmt.Sprintf("http://%s:%d", pact.Host, pact.Server.Port), }, }) - mockStore.EXPECT().GetNamespace().Return("sdk-heartbeat-namespace") - mockStore.EXPECT().GetReplicatedID().Return("sdk-heartbeat-cluster-id") - mockStore.EXPECT().GetAppID().Return("sdk-heartbeat-app") - mockStore.EXPECT().GetChannelID().Return("sdk-heartbeat-app-beta") + mockStore.EXPECT().GetNamespace().Return("replicated-sdk-instance-namespace") + mockStore.EXPECT().GetReplicatedID().Return("replicated-sdk-instance-cluster-id") + mockStore.EXPECT().GetAppID().Return("replicated-sdk-instance-app") + mockStore.EXPECT().GetChannelID().Return("replicated-sdk-instance-app-beta") mockStore.EXPECT().GetChannelName().Return("Beta") mockStore.EXPECT().GetChannelSequence().Return(int64(1)) mockStore.EXPECT().GetAppStatus().Times(2).Return(appstatetypes.AppStatus{ - AppSlug: "sdk-heartbeat-app", + AppSlug: "replicated-sdk-instance-app", Sequence: 1, State: appstatetypes.StateMissing, ResourceStates: []appstatetypes.ResourceState{}, @@ -113,18 +113,18 @@ func TestSendAppHeartbeat(t *testing.T) { pact. AddInteraction(). Given("License exists and is expired"). - UponReceiving("A heartbeat from the Replicated SDK"). + UponReceiving("Instance data from the Replicated SDK"). WithRequest(dsl.Request{ Method: http.MethodPost, Headers: dsl.MapMatcher{ "User-Agent": dsl.String("Replicated-SDK/v0.0.0-unknown"), - "Authorization": dsl.String(fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", "sdk-heartbeat-customer-2-license", "sdk-heartbeat-customer-2-license"))))), + "Authorization": dsl.String(fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", "replicated-sdk-instance-customer-2-license", "replicated-sdk-instance-customer-2-license"))))), "Content-Type": dsl.String("application/json"), "X-Replicated-K8sVersion": dsl.Like("v1.25.3"), "X-Replicated-AppStatus": dsl.String("missing"), - "X-Replicated-ClusterID": dsl.String("sdk-heartbeat-cluster-id"), - "X-Replicated-InstanceID": dsl.String("sdk-heartbeat-app"), - "X-Replicated-DownstreamChannelID": dsl.String("sdk-heartbeat-app-beta"), + "X-Replicated-ClusterID": dsl.String("replicated-sdk-instance-cluster-id"), + "X-Replicated-InstanceID": dsl.String("replicated-sdk-instance-app"), + "X-Replicated-DownstreamChannelID": dsl.String("replicated-sdk-instance-app-beta"), "X-Replicated-DownstreamChannelSequence": dsl.String("1"), }, Path: dsl.String("/kots_metrics/license_instance/info"), @@ -139,22 +139,22 @@ func TestSendAppHeartbeat(t *testing.T) { wantErr: true, }, { - name: "nonexistent license heartbeat should return error", + name: "nonexistent license should return error", mockStoreExpectations: func() { mockStore.EXPECT().GetLicense().Return(&v1beta1.License{ Spec: v1beta1.LicenseSpec{ - LicenseID: "sdk-heartbeat-customer-nonexistent-license", + LicenseID: "replicated-sdk-instance-customer-nonexistent-license", Endpoint: fmt.Sprintf("http://%s:%d", pact.Host, pact.Server.Port), }, }) - mockStore.EXPECT().GetNamespace().Return("sdk-heartbeat-namespace") - mockStore.EXPECT().GetReplicatedID().Return("sdk-heartbeat-cluster-id") - mockStore.EXPECT().GetAppID().Return("sdk-heartbeat-app") - mockStore.EXPECT().GetChannelID().Return("sdk-heartbeat-app-beta") + mockStore.EXPECT().GetNamespace().Return("replicated-sdk-instance-namespace") + mockStore.EXPECT().GetReplicatedID().Return("replicated-sdk-instance-cluster-id") + mockStore.EXPECT().GetAppID().Return("replicated-sdk-instance-app") + mockStore.EXPECT().GetChannelID().Return("replicated-sdk-instance-app-beta") mockStore.EXPECT().GetChannelName().Return("Beta") mockStore.EXPECT().GetChannelSequence().Return(int64(1)) mockStore.EXPECT().GetAppStatus().Times(2).Return(appstatetypes.AppStatus{ - AppSlug: "sdk-heartbeat-app", + AppSlug: "replicated-sdk-instance-app", Sequence: 1, State: appstatetypes.StateMissing, ResourceStates: []appstatetypes.ResourceState{}, @@ -164,18 +164,18 @@ func TestSendAppHeartbeat(t *testing.T) { pact. AddInteraction(). Given("License does not exist"). - UponReceiving("A heartbeat from the Replicated SDK"). + UponReceiving("Instance data from the Replicated SDK"). WithRequest(dsl.Request{ Method: http.MethodPost, Headers: dsl.MapMatcher{ "User-Agent": dsl.String("Replicated-SDK/v0.0.0-unknown"), - "Authorization": dsl.String(fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", "sdk-heartbeat-customer-nonexistent-license", "sdk-heartbeat-customer-nonexistent-license"))))), + "Authorization": dsl.String(fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", "replicated-sdk-instance-customer-nonexistent-license", "replicated-sdk-instance-customer-nonexistent-license"))))), "Content-Type": dsl.String("application/json"), "X-Replicated-K8sVersion": dsl.Like("v1.25.3"), "X-Replicated-AppStatus": dsl.String("missing"), - "X-Replicated-ClusterID": dsl.String("sdk-heartbeat-cluster-id"), - "X-Replicated-InstanceID": dsl.String("sdk-heartbeat-app"), - "X-Replicated-DownstreamChannelID": dsl.String("sdk-heartbeat-app-beta"), + "X-Replicated-ClusterID": dsl.String("replicated-sdk-instance-cluster-id"), + "X-Replicated-InstanceID": dsl.String("replicated-sdk-instance-app"), + "X-Replicated-DownstreamChannelID": dsl.String("replicated-sdk-instance-app-beta"), "X-Replicated-DownstreamChannelSequence": dsl.String("1"), }, Path: dsl.String("/kots_metrics/license_instance/info"), @@ -190,22 +190,22 @@ func TestSendAppHeartbeat(t *testing.T) { wantErr: true, }, { - name: "unauthenticated heartbeat should return error", + name: "unauthenticated instance data request should return error", mockStoreExpectations: func() { mockStore.EXPECT().GetLicense().Return(&v1beta1.License{ Spec: v1beta1.LicenseSpec{ - LicenseID: "sdk-heartbeat-customer-0-license", + LicenseID: "replicated-sdk-instance-customer-0-license", Endpoint: fmt.Sprintf("http://%s:%d", pact.Host, pact.Server.Port), }, }) - mockStore.EXPECT().GetNamespace().Return("sdk-heartbeat-namespace") - 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().GetNamespace().Return("replicated-sdk-instance-namespace") + mockStore.EXPECT().GetReplicatedID().Return("replicated-sdk-instance-cluster-id") + mockStore.EXPECT().GetAppID().Return("replicated-sdk-instance-app") + mockStore.EXPECT().GetChannelID().Return("replicated-sdk-instance-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", + AppSlug: "replicated-sdk-instance-app", Sequence: 1, State: appstatetypes.StateMissing, ResourceStates: []appstatetypes.ResourceState{}, @@ -215,7 +215,7 @@ func TestSendAppHeartbeat(t *testing.T) { pact. AddInteraction(). Given("License exists and is not expired"). - UponReceiving("An unauthenticated heartbeat from the Replicated SDK"). + UponReceiving("An unauthenticated instance data request from the Replicated SDK"). WithRequest(dsl.Request{ Method: http.MethodPost, Headers: dsl.MapMatcher{ @@ -223,9 +223,9 @@ func TestSendAppHeartbeat(t *testing.T) { "Content-Type": dsl.String("application/json"), "X-Replicated-K8sVersion": dsl.Like("v1.25.3"), "X-Replicated-AppStatus": dsl.String("missing"), - "X-Replicated-ClusterID": dsl.String("sdk-heartbeat-cluster-id"), - "X-Replicated-InstanceID": dsl.String("sdk-heartbeat-app"), - "X-Replicated-DownstreamChannelID": dsl.String("sdk-heartbeat-app-nightly"), + "X-Replicated-ClusterID": dsl.String("replicated-sdk-instance-cluster-id"), + "X-Replicated-InstanceID": dsl.String("replicated-sdk-instance-app"), + "X-Replicated-DownstreamChannelID": dsl.String("replicated-sdk-instance-app-nightly"), "X-Replicated-DownstreamChannelSequence": dsl.String("1"), }, Path: dsl.String("/kots_metrics/license_instance/info"), @@ -245,8 +245,8 @@ func TestSendAppHeartbeat(t *testing.T) { tt.mockStoreExpectations() tt.pactInteraction() if err := pact.Verify(func() error { - if err := heartbeat.SendAppHeartbeat(clientset, mockStore); (err != nil) != tt.wantErr { - t.Errorf("SendAppHeartbeat() error = %v, wantErr %v", err, tt.wantErr) + if err := report.SendInstanceData(clientset, mockStore); (err != nil) != tt.wantErr { + t.Errorf("SendInstanceData() error = %v, wantErr %v", err, tt.wantErr) } return nil }); err != nil { diff --git a/pact/license_test.go b/pact/license_test.go index 0b76626d..082ce64a 100644 --- a/pact/license_test.go +++ b/pact/license_test.go @@ -18,11 +18,11 @@ kind: License metadata: name: sdklicenseappcustomer0 spec: - licenseID: sdk-license-customer-0-license + licenseID: replicated-sdk-license-customer-0-license licenseType: trial customerName: SDK License App Customer 0 - appSlug: sdk-license-app - channelID: sdk-license-app-nightly + appSlug: replicated-sdk-license-app + channelID: replicated-sdk-license-app-nightly channelName: Nightly licenseSequence: 2 endpoint: http://replicated-app:3000 @@ -58,8 +58,8 @@ spec: args: args{ license: &v1beta1.License{ Spec: v1beta1.LicenseSpec{ - LicenseID: "sdk-license-customer-0-license", - AppSlug: "sdk-license-app", + LicenseID: "replicated-sdk-license-customer-0-license", + AppSlug: "replicated-sdk-license-app", Endpoint: fmt.Sprintf("http://%s:%d", pact.Host, pact.Server.Port), }, }, @@ -73,9 +73,9 @@ spec: Method: http.MethodGet, Headers: dsl.MapMatcher{ "User-Agent": dsl.String("Replicated-SDK/v0.0.0-unknown"), - "Authorization": dsl.String(fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", "sdk-license-customer-0-license", "sdk-license-customer-0-license"))))), + "Authorization": dsl.String(fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", "replicated-sdk-license-customer-0-license", "replicated-sdk-license-customer-0-license"))))), }, - Path: dsl.String(fmt.Sprintf("/license/%s", "sdk-license-app")), + Path: dsl.String(fmt.Sprintf("/license/%s", "replicated-sdk-license-app")), }). WillRespondWith(dsl.Response{ Status: http.StatusOK, @@ -94,7 +94,7 @@ spec: license: &v1beta1.License{ Spec: v1beta1.LicenseSpec{ LicenseID: "not-a-customer-license", - AppSlug: "sdk-license-app", + AppSlug: "replicated-sdk-license-app", Endpoint: fmt.Sprintf("http://%s:%d", pact.Host, pact.Server.Port), }, }, @@ -110,7 +110,7 @@ spec: "User-Agent": dsl.String("Replicated-SDK/v0.0.0-unknown"), "Authorization": dsl.String(fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", "not-a-customer-license", "not-a-customer-license"))))), }, - Path: dsl.String(fmt.Sprintf("/license/%s", "sdk-license-app")), + Path: dsl.String(fmt.Sprintf("/license/%s", "replicated-sdk-license-app")), }). WillRespondWith(dsl.Response{ Status: http.StatusUnauthorized, @@ -123,7 +123,7 @@ spec: args: args{ license: &v1beta1.License{ Spec: v1beta1.LicenseSpec{ - LicenseID: "sdk-license-customer-0-license", + LicenseID: "replicated-sdk-license-customer-0-license", AppSlug: "not-an-app", Endpoint: fmt.Sprintf("http://%s:%d", pact.Host, pact.Server.Port), }, @@ -138,7 +138,7 @@ spec: Method: http.MethodGet, Headers: dsl.MapMatcher{ "User-Agent": dsl.String("Replicated-SDK/v0.0.0-unknown"), - "Authorization": dsl.String(fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", "sdk-license-customer-0-license", "sdk-license-customer-0-license"))))), + "Authorization": dsl.String(fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", "replicated-sdk-license-customer-0-license", "replicated-sdk-license-customer-0-license"))))), }, Path: dsl.String(fmt.Sprintf("/license/%s", "not-an-app")), }). @@ -153,8 +153,8 @@ spec: args: args{ license: &v1beta1.License{ Spec: v1beta1.LicenseSpec{ - LicenseID: "sdk-license-customer-0-license", - AppSlug: "sdk-heartbeat-app", + LicenseID: "replicated-sdk-license-customer-0-license", + AppSlug: "replicated-sdk-instance-app", Endpoint: fmt.Sprintf("http://%s:%d", pact.Host, pact.Server.Port), }, }, @@ -169,9 +169,9 @@ spec: Method: http.MethodGet, Headers: dsl.MapMatcher{ "User-Agent": dsl.String("Replicated-SDK/v0.0.0-unknown"), - "Authorization": dsl.String(fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", "sdk-license-customer-0-license", "sdk-license-customer-0-license"))))), + "Authorization": dsl.String(fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", "replicated-sdk-license-customer-0-license", "replicated-sdk-license-customer-0-license"))))), }, - Path: dsl.String(fmt.Sprintf("/license/%s", "sdk-heartbeat-app")), + Path: dsl.String(fmt.Sprintf("/license/%s", "replicated-sdk-instance-app")), }). WillRespondWith(dsl.Response{ Status: http.StatusUnauthorized, @@ -184,8 +184,8 @@ spec: args: args{ license: &v1beta1.License{ Spec: v1beta1.LicenseSpec{ - LicenseID: "sdk-license-customer-archived-license", - AppSlug: "sdk-license-app", + LicenseID: "replicated-sdk-license-customer-archived-license", + AppSlug: "replicated-sdk-license-app", Endpoint: fmt.Sprintf("http://%s:%d", pact.Host, pact.Server.Port), }, }, @@ -199,9 +199,9 @@ spec: Method: http.MethodGet, Headers: dsl.MapMatcher{ "User-Agent": dsl.String("Replicated-SDK/v0.0.0-unknown"), - "Authorization": dsl.String(fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", "sdk-license-customer-archived-license", "sdk-license-customer-archived-license"))))), + "Authorization": dsl.String(fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", "replicated-sdk-license-customer-archived-license", "replicated-sdk-license-customer-archived-license"))))), }, - Path: dsl.String(fmt.Sprintf("/license/%s", "sdk-license-app")), + Path: dsl.String(fmt.Sprintf("/license/%s", "replicated-sdk-license-app")), }). WillRespondWith(dsl.Response{ Status: http.StatusForbidden, diff --git a/pkg/appstate/operator.go b/pkg/appstate/operator.go index 31a48d9f..cc6695ad 100644 --- a/pkg/appstate/operator.go +++ b/pkg/appstate/operator.go @@ -9,9 +9,9 @@ import ( "github.com/mitchellh/hashstructure" "github.com/pkg/errors" "github.com/replicatedhq/replicated-sdk/pkg/appstate/types" - "github.com/replicatedhq/replicated-sdk/pkg/heartbeat" "github.com/replicatedhq/replicated-sdk/pkg/k8sutil" "github.com/replicatedhq/replicated-sdk/pkg/logger" + "github.com/replicatedhq/replicated-sdk/pkg/report" "github.com/replicatedhq/replicated-sdk/pkg/store" "github.com/replicatedhq/replicated-sdk/pkg/util" "k8s.io/client-go/kubernetes" @@ -130,11 +130,11 @@ func (o *Operator) setAppStatus(newAppStatus types.AppStatus) error { go func() { clientset, err := k8sutil.GetClientset() if err != nil { - logger.Error(errors.Wrap(err, "failed to get clientset to send heartbeat")) + logger.Error(errors.Wrap(err, "failed to get clientset")) return } - if err := heartbeat.SendAppHeartbeat(clientset, store.GetStore()); err != nil { - logger.Error(errors.Wrap(err, "failed to send heartbeat")) + if err := report.SendInstanceData(clientset, store.GetStore()); err != nil { + logger.Error(errors.Wrap(err, "failed to send instance data")) } }() } diff --git a/pkg/handlers/app.go b/pkg/handlers/app.go index 059178ba..c81bcda6 100644 --- a/pkg/handlers/app.go +++ b/pkg/handlers/app.go @@ -17,7 +17,7 @@ import ( "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/report" "github.com/replicatedhq/replicated-sdk/pkg/store" "github.com/replicatedhq/replicated-sdk/pkg/upstream" upstreamtypes "github.com/replicatedhq/replicated-sdk/pkg/upstream/types" @@ -331,13 +331,6 @@ func mockReleaseToAppRelease(mockRelease integrationtypes.MockRelease) AppReleas } 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")) @@ -351,8 +344,14 @@ func SendCustomAppMetrics(w http.ResponseWriter, r *http.Request) { return } - err := metrics.SendCustomAppMetricsData(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 + } + + if err := report.SendCustomAppMetrics(clientset, store.GetStore(), request.Data); err != nil { logger.Error(errors.Wrap(err, "set application data")) w.WriteHeader(http.StatusBadRequest) return diff --git a/pkg/heartbeat/heartbeat.go b/pkg/heartbeat/heartbeat.go index 710b5c27..4b13c910 100644 --- a/pkg/heartbeat/heartbeat.go +++ b/pkg/heartbeat/heartbeat.go @@ -9,6 +9,7 @@ import ( "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/report" "github.com/replicatedhq/replicated-sdk/pkg/store" "github.com/replicatedhq/replicated-sdk/pkg/util" cron "github.com/robfig/cron/v3" @@ -63,11 +64,11 @@ func Start() error { go func() { clientset, err := k8sutil.GetClientset() if err != nil { - logger.Error(errors.Wrap(err, "failed to get clientset to send heartbeat")) + logger.Error(errors.Wrap(err, "failed to get clientset")) return } - if err := SendAppHeartbeat(clientset, store.GetStore()); err != nil { - logger.Error(errors.Wrap(err, "failed to send heartbeat")) + if err := report.SendInstanceData(clientset, store.GetStore()); err != nil { + logger.Error(errors.Wrap(err, "failed to send instance data")) } }() }) diff --git a/pkg/heartbeat/instance_report.go b/pkg/heartbeat/instance_report.go deleted file mode 100644 index 96b559df..00000000 --- a/pkg/heartbeat/instance_report.go +++ /dev/null @@ -1,141 +0,0 @@ -package heartbeat - -import ( - "context" - "encoding/base64" - "encoding/json" - "sync" - - "github.com/pkg/errors" - heartbeattypes "github.com/replicatedhq/replicated-sdk/pkg/heartbeat/types" - "github.com/replicatedhq/replicated-sdk/pkg/util" - corev1 "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" -) - -const ( - InstanceReportSecretName = "replicated-instance-report" - InstanceReportSecretKey = "report" - InstanceReportEventLimit = 4000 -) - -var instanceReportMtx = sync.Mutex{} - -func CreateInstanceReportEvent(clientset kubernetes.Interface, namespace string, event heartbeattypes.InstanceReportEvent) error { - instanceReportMtx.Lock() - defer instanceReportMtx.Unlock() - - existingSecret, err := clientset.CoreV1().Secrets(namespace).Get(context.TODO(), InstanceReportSecretName, metav1.GetOptions{}) - if err != nil && !kuberneteserrors.IsNotFound(err) { - return errors.Wrap(err, "failed to get airgap instance report secret") - } else if kuberneteserrors.IsNotFound(err) { - instanceReport := &heartbeattypes.InstanceReport{ - Events: []heartbeattypes.InstanceReportEvent{event}, - } - data, err := EncodeInstanceReport(instanceReport) - if err != nil { - return errors.Wrap(err, "failed to encode instance report") - } - - uid, err := util.GetReplicatedDeploymentUID(clientset, namespace) - if err != nil { - return errors.Wrap(err, "failed to get replicated deployment uid") - } - - secret := &corev1.Secret{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "v1", - Kind: "Secret", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: InstanceReportSecretName, - Namespace: namespace, - // since this secret is created by the replicated deployment, we should set the owner reference - // so that it is deleted when the replicated deployment is deleted - OwnerReferences: []metav1.OwnerReference{ - { - APIVersion: "apps/v1", - Kind: "Deployment", - Name: util.GetReplicatedDeploymentName(), - UID: uid, - }, - }, - }, - Data: map[string][]byte{ - InstanceReportSecretKey: data, - }, - } - - _, err = clientset.CoreV1().Secrets(namespace).Create(context.TODO(), secret, metav1.CreateOptions{}) - if err != nil { - return errors.Wrap(err, "failed to create airgap instance report secret") - } - - return nil - } - - if existingSecret.Data == nil { - existingSecret.Data = map[string][]byte{} - } - - existingInstanceReport := &heartbeattypes.InstanceReport{} - if existingSecret.Data[InstanceReportSecretKey] != nil { - existingInstanceReport, err = DecodeInstanceReport(existingSecret.Data[InstanceReportSecretKey]) - if err != nil { - return errors.Wrap(err, "failed to load existing instance report") - } - } - - existingInstanceReport.Events = append(existingInstanceReport.Events, event) - if len(existingInstanceReport.Events) > InstanceReportEventLimit { - existingInstanceReport.Events = existingInstanceReport.Events[len(existingInstanceReport.Events)-InstanceReportEventLimit:] - } - - data, err := EncodeInstanceReport(existingInstanceReport) - if err != nil { - return errors.Wrap(err, "failed to encode existing instance report") - } - - existingSecret.Data[InstanceReportSecretKey] = data - - _, err = clientset.CoreV1().Secrets(namespace).Update(context.TODO(), existingSecret, metav1.UpdateOptions{}) - if err != nil { - return errors.Wrap(err, "failed to update airgap instance report secret") - } - - return nil -} - -func EncodeInstanceReport(r *heartbeattypes.InstanceReport) ([]byte, error) { - data, err := json.Marshal(r) - if err != nil { - return nil, errors.Wrap(err, "failed to marshal instance report") - } - compressedData, err := util.GzipData(data) - if err != nil { - return nil, errors.Wrap(err, "failed to gzip instance report") - } - encodedData := base64.StdEncoding.EncodeToString(compressedData) - - return []byte(encodedData), nil -} - -func DecodeInstanceReport(encodedData []byte) (*heartbeattypes.InstanceReport, error) { - decodedData, err := base64.StdEncoding.DecodeString(string(encodedData)) - if err != nil { - return nil, errors.Wrap(err, "failed to decode instance report") - } - decompressedData, err := util.GunzipData(decodedData) - if err != nil { - return nil, errors.Wrap(err, "failed to gunzip instance report") - } - - instanceReport := &heartbeattypes.InstanceReport{} - if err := json.Unmarshal(decompressedData, instanceReport); err != nil { - return nil, errors.Wrap(err, "failed to unmarshal instance report") - } - - return instanceReport, nil -} diff --git a/pkg/heartbeat/instance_report_test.go b/pkg/heartbeat/instance_report_test.go deleted file mode 100644 index 0ca00c3d..00000000 --- a/pkg/heartbeat/instance_report_test.go +++ /dev/null @@ -1,229 +0,0 @@ -package heartbeat - -import ( - "context" - "testing" - - heartbeattypes "github.com/replicatedhq/replicated-sdk/pkg/heartbeat/types" - "github.com/replicatedhq/replicated-sdk/pkg/util" - "github.com/stretchr/testify/require" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/kubernetes/fake" -) - -func Test_CreateInstanceReportEvent(t *testing.T) { - testEvent := heartbeattypes.InstanceReportEvent{ - ReportedAt: 1234567890, - LicenseID: "test-license-id", - InstanceID: "test-instance-id", - ClusterID: "test-cluster-id", - UserAgent: "test-user-agent", - AppStatus: "ready", - ResourceStates: "[]", - K8sVersion: "1.29.0", - K8sDistribution: "test-distribution", - DownstreamChannelID: "test-channel-id", - DownstreamChannelName: "test-channel-name", - DownstreamChannelSequence: 1, - } - - testReportWithOneEvent := &heartbeattypes.InstanceReport{ - Events: []heartbeattypes.InstanceReportEvent{testEvent}, - } - testReportWithOneEventData, err := EncodeInstanceReport(testReportWithOneEvent) - require.NoError(t, err) - - testReportWithMaxEvents := &heartbeattypes.InstanceReport{} - for i := 0; i < InstanceReportEventLimit; i++ { - testReportWithMaxEvents.Events = append(testReportWithMaxEvents.Events, testEvent) - } - testReportWithMaxEventsData, err := EncodeInstanceReport(testReportWithMaxEvents) - require.NoError(t, err) - - type args struct { - clientset kubernetes.Interface - namespace string - event heartbeattypes.InstanceReportEvent - } - tests := []struct { - name string - args args - wantNumEvents int - wantErr bool - }{ - { - name: "secret does not exist", - args: args{ - clientset: fake.NewSimpleClientset(&appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: util.GetReplicatedDeploymentName(), - Namespace: "default", - UID: "test-uid", - }, - }), - namespace: "default", - event: testEvent, - }, - wantNumEvents: 1, - }, - { - name: "secret exists with an existing event", - args: args{ - clientset: fake.NewSimpleClientset( - &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: util.GetReplicatedDeploymentName(), - Namespace: "default", - UID: "test-uid", - }, - }, - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: InstanceReportSecretName, - Namespace: "default", - OwnerReferences: []metav1.OwnerReference{ - { - APIVersion: "apps/v1", - Kind: "Deployment", - Name: util.GetReplicatedDeploymentName(), - UID: "test-uid", - }, - }, - }, - Data: map[string][]byte{ - InstanceReportSecretKey: testReportWithOneEventData, - }, - }, - ), - namespace: "default", - event: testEvent, - }, - wantNumEvents: 2, - }, - { - name: "secret exists without data", - args: args{ - clientset: fake.NewSimpleClientset( - &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: util.GetReplicatedDeploymentName(), - Namespace: "default", - UID: "test-uid", - }, - }, - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: InstanceReportSecretName, - Namespace: "default", - OwnerReferences: []metav1.OwnerReference{ - { - APIVersion: "apps/v1", - Kind: "Deployment", - Name: util.GetReplicatedDeploymentName(), - UID: "test-uid", - }, - }, - }, - }, - ), - namespace: "default", - event: testEvent, - }, - wantNumEvents: 1, - }, - { - name: "secret exists with max number of events", - args: args{ - clientset: fake.NewSimpleClientset( - &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: util.GetReplicatedDeploymentName(), - Namespace: "default", - UID: "test-uid", - }, - }, - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: InstanceReportSecretName, - Namespace: "default", - OwnerReferences: []metav1.OwnerReference{ - { - APIVersion: "apps/v1", - Kind: "Deployment", - Name: util.GetReplicatedDeploymentName(), - UID: "test-uid", - }, - }, - }, - Data: map[string][]byte{ - InstanceReportSecretKey: testReportWithMaxEventsData, - }, - }, - ), - namespace: "default", - event: testEvent, - }, - wantNumEvents: InstanceReportEventLimit, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := require.New(t) - - err := CreateInstanceReportEvent(tt.args.clientset, tt.args.namespace, tt.args.event) - if tt.wantErr { - req.Error(err) - return - } - req.NoError(err) - - // validate secret exists and has the expected data - secret, err := tt.args.clientset.CoreV1().Secrets(tt.args.namespace).Get(context.TODO(), InstanceReportSecretName, metav1.GetOptions{}) - req.NoError(err) - req.NotNil(secret.Data[InstanceReportSecretKey]) - - report, err := DecodeInstanceReport(secret.Data[InstanceReportSecretKey]) - req.NoError(err) - - req.Len(report.Events, tt.wantNumEvents) - - for _, event := range report.Events { - req.Equal(testEvent, event) - } - }) - } -} - -func Test_InstanceReportEncodeDecode(t *testing.T) { - req := require.New(t) - - testReport := &heartbeattypes.InstanceReport{ - Events: []heartbeattypes.InstanceReportEvent{ - { - ReportedAt: 1234567890, - LicenseID: "test-license-id", - InstanceID: "test-instance-id", - ClusterID: "test-cluster-id", - UserAgent: "test-user-agent", - AppStatus: "ready", - ResourceStates: "[]", - K8sVersion: "1.29.0", - K8sDistribution: "test-distribution", - DownstreamChannelID: "test-channel-id", - DownstreamChannelName: "test-channel-name", - DownstreamChannelSequence: 1, - }, - }, - } - - encoded, err := EncodeInstanceReport(testReport) - req.NoError(err) - - decoded, err := DecodeInstanceReport(encoded) - req.NoError(err) - - req.Equal(testReport, decoded) -} diff --git a/pkg/metrics/metrics.go b/pkg/report/custom_app_metrics.go similarity index 58% rename from pkg/metrics/metrics.go rename to pkg/report/custom_app_metrics.go index d2a4de97..b44aac17 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/report/custom_app_metrics.go @@ -1,4 +1,4 @@ -package metrics +package report import ( "bytes" @@ -7,15 +7,43 @@ import ( "io" "net/http" "net/url" + "time" "github.com/pkg/errors" - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" - "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 SendCustomAppMetricsData(sdkStore store.Store, license *kotsv1beta1.License, data map[string]interface{}) error { +func SendCustomAppMetrics(clientset kubernetes.Interface, sdkStore store.Store, data map[string]interface{}) error { + if util.IsAirgap() { + return SendAirgapCustomAppMetrics(clientset, sdkStore, data) + } + return SendOnlineCustomAppMetrics(sdkStore, data) +} + +func SendAirgapCustomAppMetrics(clientset kubernetes.Interface, sdkStore store.Store, data map[string]interface{}) error { + report := &CustomAppMetricsReport{ + Events: []CustomAppMetricsReportEvent{ + { + ReportedAt: time.Now().UTC().UnixMilli(), + LicenseID: sdkStore.GetLicense().Spec.LicenseID, + InstanceID: sdkStore.GetAppID(), + Data: data, + }, + }, + } + + if err := AppendReport(clientset, sdkStore.GetNamespace(), report); err != nil { + return errors.Wrap(err, "failed to append custom app metrics report") + } + + return nil +} + +func SendOnlineCustomAppMetrics(sdkStore store.Store, data map[string]interface{}) error { + license := sdkStore.GetLicense() + endpoint := sdkStore.GetReplicatedAppEndpoint() if endpoint == "" { endpoint = license.Spec.Endpoint @@ -52,8 +80,8 @@ func SendCustomAppMetricsData(sdkStore store.Store, license *kotsv1beta1.License req.SetBasicAuth(license.Spec.LicenseID, license.Spec.LicenseID) req.Header.Set("Content-Type", "application/json") - heartbeatInfo := heartbeat.GetHeartbeatInfo(sdkStore) - heartbeat.InjectHeartbeatInfoHeaders(req, heartbeatInfo) + instanceData := GetInstanceData(sdkStore) + InjectInstanceDataHeaders(req, instanceData) resp, err := http.DefaultClient.Do(req) if err != nil { diff --git a/pkg/report/custom_app_metrics_report.go b/pkg/report/custom_app_metrics_report.go new file mode 100644 index 00000000..ae08712c --- /dev/null +++ b/pkg/report/custom_app_metrics_report.go @@ -0,0 +1,75 @@ +package report + +import ( + "fmt" + "sync" + + "github.com/pkg/errors" +) + +var customAppMetricsReportMtx = sync.Mutex{} + +type CustomAppMetricsReport struct { + Events []CustomAppMetricsReportEvent `json:"events"` +} + +type CustomAppMetricsReportEvent struct { + ReportedAt int64 `json:"reported_at"` + LicenseID string `json:"license_id"` + InstanceID string `json:"instance_id"` + Data map[string]interface{} `json:"data"` +} + +func (r *CustomAppMetricsReport) GetType() ReportType { + return ReportTypeCustomAppMetrics +} + +func (r *CustomAppMetricsReport) GetSecretName() string { + return fmt.Sprintf(ReportSecretNameFormat, r.GetType()) +} + +func (r *CustomAppMetricsReport) GetSecretKey() string { + return ReportSecretKey +} + +func (r *CustomAppMetricsReport) AppendEvents(report Report) error { + reportToAppend, ok := report.(*CustomAppMetricsReport) + if !ok { + return errors.Errorf("report is not a custom app metrics report") + } + + r.Events = append(r.Events, reportToAppend.Events...) + if len(r.Events) > r.GetEventLimit() { + r.Events = r.Events[len(r.Events)-r.GetEventLimit():] + } + + // remove one event at a time until the report is under the size limit + encoded, err := EncodeReport(r) + if err != nil { + return errors.Wrap(err, "failed to encode report") + } + for len(encoded) > r.GetSizeLimit() { + r.Events = r.Events[1:] + if len(r.Events) == 0 { + return errors.Errorf("size of latest event exceeds report size limit") + } + encoded, err = EncodeReport(r) + if err != nil { + return errors.Wrap(err, "failed to encode report") + } + } + + return nil +} + +func (r *CustomAppMetricsReport) GetEventLimit() int { + return ReportEventLimit +} + +func (r *CustomAppMetricsReport) GetSizeLimit() int { + return ReportSizeLimit +} + +func (r *CustomAppMetricsReport) GetMtx() *sync.Mutex { + return &customAppMetricsReportMtx +} diff --git a/pkg/heartbeat/distribution.go b/pkg/report/distribution.go similarity index 97% rename from pkg/heartbeat/distribution.go rename to pkg/report/distribution.go index eee53154..759a356b 100644 --- a/pkg/heartbeat/distribution.go +++ b/pkg/report/distribution.go @@ -1,12 +1,12 @@ -package heartbeat +package report import ( "context" "strings" - "github.com/replicatedhq/replicated-sdk/pkg/heartbeat/types" "github.com/replicatedhq/replicated-sdk/pkg/k8sutil" "github.com/replicatedhq/replicated-sdk/pkg/logger" + "github.com/replicatedhq/replicated-sdk/pkg/report/types" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" ) diff --git a/pkg/heartbeat/distribution_test.go b/pkg/report/distribution_test.go similarity index 98% rename from pkg/heartbeat/distribution_test.go rename to pkg/report/distribution_test.go index 1f17110b..f79a6ee4 100644 --- a/pkg/heartbeat/distribution_test.go +++ b/pkg/report/distribution_test.go @@ -1,10 +1,10 @@ -package heartbeat +package report import ( "reflect" "testing" - "github.com/replicatedhq/replicated-sdk/pkg/heartbeat/types" + "github.com/replicatedhq/replicated-sdk/pkg/report/types" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" diff --git a/pkg/heartbeat/app.go b/pkg/report/instance.go similarity index 62% rename from pkg/heartbeat/app.go rename to pkg/report/instance.go index 53f4dd10..8587a858 100644 --- a/pkg/heartbeat/app.go +++ b/pkg/report/instance.go @@ -1,4 +1,4 @@ -package heartbeat +package report import ( "bytes" @@ -12,17 +12,17 @@ import ( "github.com/pkg/errors" "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/replicatedhq/replicated-sdk/pkg/buildversion" - "github.com/replicatedhq/replicated-sdk/pkg/heartbeat/types" "github.com/replicatedhq/replicated-sdk/pkg/k8sutil" "github.com/replicatedhq/replicated-sdk/pkg/logger" + "github.com/replicatedhq/replicated-sdk/pkg/report/types" "github.com/replicatedhq/replicated-sdk/pkg/store" "github.com/replicatedhq/replicated-sdk/pkg/util" "k8s.io/client-go/kubernetes" ) -var heartbeatMtx sync.Mutex +var instanceDataMtx sync.Mutex -func SendAppHeartbeat(clientset kubernetes.Interface, sdkStore store.Store) error { +func SendInstanceData(clientset kubernetes.Interface, sdkStore store.Store) error { license := sdkStore.GetLicense() canReport, err := canReport(clientset, sdkStore.GetNamespace(), license) @@ -34,56 +34,60 @@ func SendAppHeartbeat(clientset kubernetes.Interface, sdkStore store.Store) erro } // make sure events are reported in order - heartbeatMtx.Lock() + instanceDataMtx.Lock() defer func() { time.Sleep(1 * time.Second) - heartbeatMtx.Unlock() + instanceDataMtx.Unlock() }() - heartbeatInfo := GetHeartbeatInfo(sdkStore) + instanceData := GetInstanceData(sdkStore) if util.IsAirgap() { - return SendAirgapHeartbeat(clientset, sdkStore.GetNamespace(), license.Spec.LicenseID, heartbeatInfo) + return SendAirgapInstanceData(clientset, sdkStore.GetNamespace(), license.Spec.LicenseID, instanceData) } - return SendOnlineHeartbeat(license, heartbeatInfo) + return SendOnlineInstanceData(license, instanceData) } -func SendAirgapHeartbeat(clientset kubernetes.Interface, namespace string, licenseID string, heartbeatInfo *types.HeartbeatInfo) error { - event := types.InstanceReportEvent{ +func SendAirgapInstanceData(clientset kubernetes.Interface, namespace string, licenseID string, instanceData *types.InstanceData) error { + event := InstanceReportEvent{ ReportedAt: time.Now().UTC().UnixMilli(), LicenseID: licenseID, - InstanceID: heartbeatInfo.InstanceID, - ClusterID: heartbeatInfo.ClusterID, + InstanceID: instanceData.InstanceID, + ClusterID: instanceData.ClusterID, UserAgent: buildversion.GetUserAgent(), - AppStatus: heartbeatInfo.AppStatus, - K8sVersion: heartbeatInfo.K8sVersion, - K8sDistribution: heartbeatInfo.K8sDistribution, - DownstreamChannelID: heartbeatInfo.ChannelID, - DownstreamChannelName: heartbeatInfo.ChannelName, - DownstreamChannelSequence: heartbeatInfo.ChannelSequence, + AppStatus: instanceData.AppStatus, + K8sVersion: instanceData.K8sVersion, + K8sDistribution: instanceData.K8sDistribution, + DownstreamChannelID: instanceData.ChannelID, + DownstreamChannelName: instanceData.ChannelName, + DownstreamChannelSequence: instanceData.ChannelSequence, } - if heartbeatInfo.ResourceStates != nil { - marshalledRS, err := json.Marshal(heartbeatInfo.ResourceStates) + if instanceData.ResourceStates != nil { + marshalledRS, err := json.Marshal(instanceData.ResourceStates) if err != nil { return errors.Wrap(err, "failed to marshal resource states") } event.ResourceStates = string(marshalledRS) } - if err := CreateInstanceReportEvent(clientset, namespace, event); err != nil { - return errors.Wrap(err, "failed to create airgap heartbeat") + report := &InstanceReport{ + Events: []InstanceReportEvent{event}, + } + + if err := AppendReport(clientset, namespace, report); err != nil { + return errors.Wrap(err, "failed to append instance report") } return nil } -func SendOnlineHeartbeat(license *v1beta1.License, heartbeatInfo *types.HeartbeatInfo) error { +func SendOnlineInstanceData(license *v1beta1.License, instanceData *types.InstanceData) error { // build the request body reqPayload := map[string]interface{}{} - if err := InjectHeartbeatInfoPayload(reqPayload, heartbeatInfo); err != nil { - return errors.Wrap(err, "failed to inject heartbeat info payload") + if err := InjectInstanceDataPayload(reqPayload, instanceData); err != nil { + return errors.Wrap(err, "failed to inject instance data payload") } reqBody, err := json.Marshal(reqPayload) if err != nil { @@ -97,7 +101,7 @@ func SendOnlineHeartbeat(license *v1beta1.License, heartbeatInfo *types.Heartbea postReq.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", license.Spec.LicenseID, license.Spec.LicenseID))))) postReq.Header.Set("Content-Type", "application/json") - InjectHeartbeatInfoHeaders(postReq, heartbeatInfo) + InjectInstanceDataHeaders(postReq, instanceData) resp, err := http.DefaultClient.Do(postReq) if err != nil { @@ -112,8 +116,8 @@ func SendOnlineHeartbeat(license *v1beta1.License, heartbeatInfo *types.Heartbea return nil } -func GetHeartbeatInfo(sdkStore store.Store) *types.HeartbeatInfo { - r := types.HeartbeatInfo{ +func GetInstanceData(sdkStore store.Store) *types.InstanceData { + r := types.InstanceData{ ClusterID: sdkStore.GetReplicatedID(), InstanceID: sdkStore.GetAppID(), ChannelID: sdkStore.GetChannelID(), diff --git a/pkg/report/instance_report.go b/pkg/report/instance_report.go new file mode 100644 index 00000000..0bba0b42 --- /dev/null +++ b/pkg/report/instance_report.go @@ -0,0 +1,83 @@ +package report + +import ( + "fmt" + "sync" + + "github.com/pkg/errors" +) + +var instanceReportMtx = sync.Mutex{} + +type InstanceReport struct { + Events []InstanceReportEvent `json:"events"` +} + +type InstanceReportEvent struct { + ReportedAt int64 `json:"reported_at"` + LicenseID string `json:"license_id"` + InstanceID string `json:"instance_id"` + ClusterID string `json:"cluster_id"` + UserAgent string `json:"user_agent"` + AppStatus string `json:"app_status,omitempty"` + ResourceStates string `json:"resource_states,omitempty"` + K8sVersion string `json:"k8s_version"` + K8sDistribution string `json:"k8s_distribution,omitempty"` + DownstreamChannelID string `json:"downstream_channel_id,omitempty"` + DownstreamChannelSequence int64 `json:"downstream_channel_sequence"` + DownstreamChannelName string `json:"downstream_channel_name,omitempty"` +} + +func (r *InstanceReport) GetType() ReportType { + return ReportTypeInstance +} + +func (r *InstanceReport) GetSecretName() string { + return fmt.Sprintf(ReportSecretNameFormat, r.GetType()) +} + +func (r *InstanceReport) GetSecretKey() string { + return ReportSecretKey +} + +func (r *InstanceReport) AppendEvents(report Report) error { + reportToAppend, ok := report.(*InstanceReport) + if !ok { + return errors.Errorf("report is not an instance report") + } + + r.Events = append(r.Events, reportToAppend.Events...) + if len(r.Events) > r.GetEventLimit() { + r.Events = r.Events[len(r.Events)-r.GetEventLimit():] + } + + // remove one event at a time until the report is under the size limit + encoded, err := EncodeReport(r) + if err != nil { + return errors.Wrap(err, "failed to encode report") + } + for len(encoded) > r.GetSizeLimit() { + r.Events = r.Events[1:] + if len(r.Events) == 0 { + return errors.Errorf("size of latest event exceeds report size limit") + } + encoded, err = EncodeReport(r) + if err != nil { + return errors.Wrap(err, "failed to encode report") + } + } + + return nil +} + +func (r *InstanceReport) GetEventLimit() int { + return ReportEventLimit +} + +func (r *InstanceReport) GetSizeLimit() int { + return ReportSizeLimit +} + +func (r *InstanceReport) GetMtx() *sync.Mutex { + return &instanceReportMtx +} diff --git a/pkg/heartbeat/app_test.go b/pkg/report/instance_test.go similarity index 93% rename from pkg/heartbeat/app_test.go rename to pkg/report/instance_test.go index 8b4278bc..4d7aaef3 100644 --- a/pkg/heartbeat/app_test.go +++ b/pkg/report/instance_test.go @@ -1,4 +1,4 @@ -package heartbeat +package report import ( "net/http" @@ -18,7 +18,7 @@ import ( "k8s.io/client-go/kubernetes/fake" ) -func Test_SendAppHeartbeat(t *testing.T) { +func Test_SendInstanceData(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockStore := mock_store.NewMockStore(ctrl) @@ -28,7 +28,7 @@ func Test_SendAppHeartbeat(t *testing.T) { mockServer := httptest.NewServer(mockRouter) defer mockServer.Close() mockRouter.Methods("POST").Path("/kots_metrics/license_instance/info").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - respRecorder.Write([]byte("received heartbeat")) + respRecorder.Write([]byte("received instance data")) w.WriteHeader(http.StatusOK) }) @@ -44,7 +44,7 @@ func Test_SendAppHeartbeat(t *testing.T) { mockStoreExpectations func() }{ { - name: "online heartbeat", + name: "send online instance data", args: args{ clientset: fake.NewSimpleClientset( k8sutil.CreateTestDeployment(util.GetReplicatedDeploymentName(), "test-namespace", "1", map[string]string{"app": "test-app"}), @@ -80,7 +80,7 @@ func Test_SendAppHeartbeat(t *testing.T) { }, }, { - name: "airgap heartbeat", + name: "send airgap instance data", args: args{ clientset: fake.NewSimpleClientset( k8sutil.CreateTestDeployment(util.GetReplicatedDeploymentName(), "test-namespace", "1", map[string]string{"app": "test-app"}), @@ -128,11 +128,11 @@ func Test_SendAppHeartbeat(t *testing.T) { tt.mockStoreExpectations() - err := SendAppHeartbeat(tt.args.clientset, tt.args.sdkStore) + err := SendInstanceData(tt.args.clientset, tt.args.sdkStore) req.NoError(err) if !tt.isAirgap { - req.Equal("received heartbeat", respRecorder.Body.String()) + req.Equal("received instance data", respRecorder.Body.String()) } else { req.Equal("", respRecorder.Body.String()) } diff --git a/pkg/report/report.go b/pkg/report/report.go new file mode 100644 index 00000000..d9d647d8 --- /dev/null +++ b/pkg/report/report.go @@ -0,0 +1,171 @@ +package report + +import ( + "context" + "encoding/base64" + "encoding/json" + "sync" + + "github.com/pkg/errors" + "github.com/replicatedhq/replicated-sdk/pkg/util" + corev1 "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" +) + +const ( + ReportSecretNameFormat = "replicated-%s-report" + ReportSecretKey = "report" + ReportEventLimit = 4000 + ReportSizeLimit = 1 * 1024 * 1024 // 1MB +) + +type ReportType string + +const ( + ReportTypeInstance ReportType = "instance" + ReportTypeCustomAppMetrics ReportType = "custom-app-metrics" +) + +type Report interface { + GetType() ReportType + GetSecretName() string + GetSecretKey() string + AppendEvents(report Report) error + GetEventLimit() int + GetSizeLimit() int + GetMtx() *sync.Mutex +} + +var _ Report = &InstanceReport{} +var _ Report = &CustomAppMetricsReport{} + +func AppendReport(clientset kubernetes.Interface, namespace string, report Report) error { + report.GetMtx().Lock() + defer report.GetMtx().Unlock() + + existingSecret, err := clientset.CoreV1().Secrets(namespace).Get(context.TODO(), report.GetSecretName(), metav1.GetOptions{}) + if err != nil && !kuberneteserrors.IsNotFound(err) { + return errors.Wrap(err, "failed to get report secret") + } + + if kuberneteserrors.IsNotFound(err) { + data, err := EncodeReport(report) + if err != nil { + return errors.Wrap(err, "failed to encode report") + } + + uid, err := util.GetReplicatedDeploymentUID(clientset, namespace) + if err != nil { + return errors.Wrap(err, "failed to get replicated deployment uid") + } + + secret := &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Secret", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: report.GetSecretName(), + Namespace: namespace, + // since this secret is created by the replicated deployment, we should set the owner reference + // so that it is deleted when the replicated deployment is deleted + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "apps/v1", + Kind: "Deployment", + Name: util.GetReplicatedDeploymentName(), + UID: uid, + }, + }, + }, + Data: map[string][]byte{ + report.GetSecretKey(): data, + }, + } + + _, err = clientset.CoreV1().Secrets(namespace).Create(context.TODO(), secret, metav1.CreateOptions{}) + if err != nil { + return errors.Wrap(err, "failed to create report secret") + } + + return nil + } + + if existingSecret.Data == nil { + existingSecret.Data = map[string][]byte{} + } + + var existingReport Report + if existingSecret.Data[report.GetSecretKey()] != nil { + existingReport, err = DecodeReport(existingSecret.Data[report.GetSecretKey()], report.GetType()) + if err != nil { + return errors.Wrap(err, "failed to load existing report") + } + + if err := existingReport.AppendEvents(report); err != nil { + return errors.Wrap(err, "failed to append events to existing report") + } + } else { + // secret exists but doesn't have the report key, so just use the report that was passed in + existingReport = report + } + + data, err := EncodeReport(existingReport) + if err != nil { + return errors.Wrap(err, "failed to encode existing report") + } + + existingSecret.Data[report.GetSecretKey()] = data + + _, err = clientset.CoreV1().Secrets(namespace).Update(context.TODO(), existingSecret, metav1.UpdateOptions{}) + if err != nil { + return errors.Wrap(err, "failed to update report secret") + } + + return nil +} + +func EncodeReport(r Report) ([]byte, error) { + data, err := json.Marshal(r) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal report") + } + compressedData, err := util.GzipData(data) + if err != nil { + return nil, errors.Wrap(err, "failed to gzip report") + } + encodedData := base64.StdEncoding.EncodeToString(compressedData) + + return []byte(encodedData), nil +} + +func DecodeReport(encodedData []byte, reportType ReportType) (Report, error) { + decodedData, err := base64.StdEncoding.DecodeString(string(encodedData)) + if err != nil { + return nil, errors.Wrap(err, "failed to decode report") + } + decompressedData, err := util.GunzipData(decodedData) + if err != nil { + return nil, errors.Wrap(err, "failed to gunzip report") + } + + var r Report + switch reportType { + case ReportTypeInstance: + r = &InstanceReport{} + if err := json.Unmarshal(decompressedData, r); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal instance report") + } + case ReportTypeCustomAppMetrics: + r = &CustomAppMetricsReport{} + if err := json.Unmarshal(decompressedData, r); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal custom app metrics report") + } + default: + return nil, errors.Errorf("unknown report type %q", reportType) + } + + return r, nil +} diff --git a/pkg/report/report_test.go b/pkg/report/report_test.go new file mode 100644 index 00000000..c46d6886 --- /dev/null +++ b/pkg/report/report_test.go @@ -0,0 +1,510 @@ +package report + +import ( + "context" + "encoding/json" + "math/rand" + "testing" + + "github.com/pkg/errors" + "github.com/replicatedhq/replicated-sdk/pkg/util" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" +) + +func Test_EncodeDecodeReport(t *testing.T) { + req := require.New(t) + + var input Report + + // instance report + input = &InstanceReport{ + Events: []InstanceReportEvent{ + createTestInstanceEvent(1234567890), + }, + } + + encoded, err := EncodeReport(input) + req.NoError(err) + + decoded, err := DecodeReport(encoded, input.GetType()) + req.NoError(err) + + req.Equal(input, decoded) + + // custom app metrics report + input = &CustomAppMetricsReport{ + Events: []CustomAppMetricsReportEvent{ + createTestCustomAppMetricsEvent(1234567890), + }, + } + + encoded, err = EncodeReport(input) + req.NoError(err) + + decoded, err = DecodeReport(encoded, input.GetType()) + req.NoError(err) + + // since values are an interface, compare the json representation + marshalledInput, err := json.MarshalIndent(input, "", " ") + req.NoError(err) + + marshalledDecoded, err := json.MarshalIndent(decoded, "", " ") + req.NoError(err) + + req.Equal(string(marshalledInput), string(marshalledDecoded)) +} + +func Test_AppendReport(t *testing.T) { + req := require.New(t) + + instanceReportWithMaxEvents := getTestInstanceReportWithMaxEvents() + instanceReportWithMaxSize, err := getTestInstanceReportWithMaxSize() + req.NoError(err) + + customAppMetricsReportWithMaxEvents := getTestCustomAppMetricsReportWithMaxEvents() + customAppMetricsReportWithMaxSize, err := getTestCustomAppMetricsReportWithMaxSize() + req.NoError(err) + + tests := []struct { + name string + existingReport Report + newReport Report + wantReport Report + }{ + { + name: "instance report - no existing report", + existingReport: nil, + newReport: &InstanceReport{ + Events: []InstanceReportEvent{ + createTestInstanceEvent(1), + createTestInstanceEvent(2), + createTestInstanceEvent(3), + }, + }, + wantReport: &InstanceReport{ + Events: []InstanceReportEvent{ + createTestInstanceEvent(1), + createTestInstanceEvent(2), + createTestInstanceEvent(3), + }, + }, + }, + { + name: "instance report - report exists with no events", + existingReport: &InstanceReport{ + Events: []InstanceReportEvent{}, + }, + newReport: &InstanceReport{ + Events: []InstanceReportEvent{ + createTestInstanceEvent(1), + createTestInstanceEvent(2), + createTestInstanceEvent(3), + }, + }, + wantReport: &InstanceReport{ + Events: []InstanceReportEvent{ + createTestInstanceEvent(1), + createTestInstanceEvent(2), + createTestInstanceEvent(3), + }, + }, + }, + { + name: "instance report - report exists with a few events", + existingReport: &InstanceReport{ + Events: []InstanceReportEvent{ + createTestInstanceEvent(1), + createTestInstanceEvent(2), + createTestInstanceEvent(3), + }, + }, + newReport: &InstanceReport{ + Events: []InstanceReportEvent{ + createTestInstanceEvent(4), + createTestInstanceEvent(5), + createTestInstanceEvent(6), + }, + }, + wantReport: &InstanceReport{ + Events: []InstanceReportEvent{ + createTestInstanceEvent(1), + createTestInstanceEvent(2), + createTestInstanceEvent(3), + createTestInstanceEvent(4), + createTestInstanceEvent(5), + createTestInstanceEvent(6), + }, + }, + }, + { + name: "instance report - report exists with max number of events", + existingReport: instanceReportWithMaxEvents, + newReport: &InstanceReport{ + Events: []InstanceReportEvent{ + createTestInstanceEvent(int64(instanceReportWithMaxEvents.GetEventLimit())), + createTestInstanceEvent(int64(instanceReportWithMaxEvents.GetEventLimit() + 1)), + createTestInstanceEvent(int64(instanceReportWithMaxEvents.GetEventLimit() + 2)), + }, + }, + wantReport: &InstanceReport{ + Events: append(instanceReportWithMaxEvents.Events[3:], []InstanceReportEvent{ + createTestInstanceEvent(int64(instanceReportWithMaxEvents.GetEventLimit())), + createTestInstanceEvent(int64(instanceReportWithMaxEvents.GetEventLimit() + 1)), + createTestInstanceEvent(int64(instanceReportWithMaxEvents.GetEventLimit() + 2)), + }...), + }, + }, + { + name: "instance report - report exists with max report size", + existingReport: instanceReportWithMaxSize, + newReport: &InstanceReport{ + Events: []InstanceReportEvent{ + createLargeTestInstanceEvent(int64(len(instanceReportWithMaxSize.Events))), + createLargeTestInstanceEvent(int64(len(instanceReportWithMaxSize.Events) + 1)), + createLargeTestInstanceEvent(int64(len(instanceReportWithMaxSize.Events) + 2)), + }, + }, + wantReport: &InstanceReport{ + Events: append(instanceReportWithMaxSize.Events[3:], []InstanceReportEvent{ + createLargeTestInstanceEvent(int64(len(instanceReportWithMaxSize.Events))), + createLargeTestInstanceEvent(int64(len(instanceReportWithMaxSize.Events) + 1)), + createLargeTestInstanceEvent(int64(len(instanceReportWithMaxSize.Events) + 2)), + }...), + }, + }, + { + name: "custom app metrics report - no existing report", + existingReport: nil, + newReport: &CustomAppMetricsReport{ + Events: []CustomAppMetricsReportEvent{ + createTestCustomAppMetricsEvent(1), + createTestCustomAppMetricsEvent(2), + createTestCustomAppMetricsEvent(3), + }, + }, + wantReport: &CustomAppMetricsReport{ + Events: []CustomAppMetricsReportEvent{ + createTestCustomAppMetricsEvent(1), + createTestCustomAppMetricsEvent(2), + createTestCustomAppMetricsEvent(3), + }, + }, + }, + { + name: "custom app metrics report - report exists with no events", + existingReport: &CustomAppMetricsReport{ + Events: []CustomAppMetricsReportEvent{}, + }, + newReport: &CustomAppMetricsReport{ + Events: []CustomAppMetricsReportEvent{ + createTestCustomAppMetricsEvent(1), + createTestCustomAppMetricsEvent(2), + createTestCustomAppMetricsEvent(3), + }, + }, + wantReport: &CustomAppMetricsReport{ + Events: []CustomAppMetricsReportEvent{ + createTestCustomAppMetricsEvent(1), + createTestCustomAppMetricsEvent(2), + createTestCustomAppMetricsEvent(3), + }, + }, + }, + { + name: "custom app metrics report - report exists with a few events", + existingReport: &CustomAppMetricsReport{ + Events: []CustomAppMetricsReportEvent{ + createTestCustomAppMetricsEvent(1), + createTestCustomAppMetricsEvent(2), + createTestCustomAppMetricsEvent(3), + }, + }, + newReport: &CustomAppMetricsReport{ + Events: []CustomAppMetricsReportEvent{ + createTestCustomAppMetricsEvent(4), + createTestCustomAppMetricsEvent(5), + createTestCustomAppMetricsEvent(6), + }, + }, + wantReport: &CustomAppMetricsReport{ + Events: []CustomAppMetricsReportEvent{ + createTestCustomAppMetricsEvent(1), + createTestCustomAppMetricsEvent(2), + createTestCustomAppMetricsEvent(3), + createTestCustomAppMetricsEvent(4), + createTestCustomAppMetricsEvent(5), + createTestCustomAppMetricsEvent(6), + }, + }, + }, + { + name: "custom app metrics report - report exists with max number of events", + existingReport: customAppMetricsReportWithMaxEvents, + newReport: &CustomAppMetricsReport{ + Events: []CustomAppMetricsReportEvent{ + createTestCustomAppMetricsEvent(int64(customAppMetricsReportWithMaxEvents.GetEventLimit())), + createTestCustomAppMetricsEvent(int64(customAppMetricsReportWithMaxEvents.GetEventLimit() + 1)), + createTestCustomAppMetricsEvent(int64(customAppMetricsReportWithMaxEvents.GetEventLimit() + 2)), + }, + }, + wantReport: &CustomAppMetricsReport{ + Events: append(customAppMetricsReportWithMaxEvents.Events[3:], []CustomAppMetricsReportEvent{ + createTestCustomAppMetricsEvent(int64(customAppMetricsReportWithMaxEvents.GetEventLimit())), + createTestCustomAppMetricsEvent(int64(customAppMetricsReportWithMaxEvents.GetEventLimit() + 1)), + createTestCustomAppMetricsEvent(int64(customAppMetricsReportWithMaxEvents.GetEventLimit() + 2)), + }...), + }, + }, + { + name: "custom app metrics report - report exists with max report size", + existingReport: customAppMetricsReportWithMaxSize, + newReport: &CustomAppMetricsReport{ + Events: []CustomAppMetricsReportEvent{ + createLargeTestCustomAppMetricsEvent(int64(len(customAppMetricsReportWithMaxSize.Events))), + createLargeTestCustomAppMetricsEvent(int64(len(customAppMetricsReportWithMaxSize.Events) + 1)), + createLargeTestCustomAppMetricsEvent(int64(len(customAppMetricsReportWithMaxSize.Events) + 2)), + }, + }, + wantReport: &CustomAppMetricsReport{ + Events: append(customAppMetricsReportWithMaxSize.Events[3:], []CustomAppMetricsReportEvent{ + createLargeTestCustomAppMetricsEvent(int64(len(customAppMetricsReportWithMaxSize.Events))), + createLargeTestCustomAppMetricsEvent(int64(len(customAppMetricsReportWithMaxSize.Events) + 1)), + createLargeTestCustomAppMetricsEvent(int64(len(customAppMetricsReportWithMaxSize.Events) + 2)), + }...), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clientsetObjects := []runtime.Object{ + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: util.GetReplicatedDeploymentName(), + Namespace: "default", + UID: "test-deployment-uid", + }, + }, + } + + if tt.existingReport != nil { + encoded, err := EncodeReport(tt.existingReport) + req.NoError(err) + + clientsetObjects = append(clientsetObjects, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: tt.existingReport.GetSecretName(), + Namespace: "default", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "apps/v1", + Kind: "Deployment", + Name: util.GetReplicatedDeploymentName(), + UID: "test-deployment-uid", + }, + }, + }, + Data: map[string][]byte{ + tt.existingReport.GetSecretKey(): encoded, + }, + }) + } + + clientset := fake.NewSimpleClientset(clientsetObjects...) + + err := AppendReport(clientset, "default", tt.newReport) + req.NoError(err) + + // validate secret exists and has the expected data + secret, err := clientset.CoreV1().Secrets("default").Get(context.TODO(), tt.wantReport.GetSecretName(), metav1.GetOptions{}) + req.NoError(err) + req.NotNil(secret.Data[tt.wantReport.GetSecretKey()]) + req.Equal(string(secret.OwnerReferences[0].UID), "test-deployment-uid") + + gotReport, err := DecodeReport(secret.Data[tt.wantReport.GetSecretKey()], tt.wantReport.GetType()) + req.NoError(err) + + if tt.wantReport.GetType() == ReportTypeInstance { + wantNumOfEvents := len(tt.wantReport.(*InstanceReport).Events) + gotNumOfEvents := len(gotReport.(*InstanceReport).Events) + + if wantNumOfEvents != gotNumOfEvents { + t.Errorf("want %d events, got %d", wantNumOfEvents, gotNumOfEvents) + return + } + + req.Equal(tt.wantReport, gotReport) + } else { + wantNumOfEvents := len(tt.wantReport.(*CustomAppMetricsReport).Events) + gotNumOfEvents := len(gotReport.(*CustomAppMetricsReport).Events) + + if wantNumOfEvents != gotNumOfEvents { + t.Errorf("want %d events, got %d", wantNumOfEvents, gotNumOfEvents) + return + } + + // since values of custom app metrics are an interface, compare the json representation + wantJSON, err := json.MarshalIndent(tt.wantReport, "", " ") + req.NoError(err) + + gotJSON, err := json.MarshalIndent(gotReport, "", " ") + req.NoError(err) + + req.Equal(string(wantJSON), string(gotJSON)) + } + }) + } +} + +func createTestInstanceEvent(reportedAt int64) InstanceReportEvent { + return InstanceReportEvent{ + ReportedAt: reportedAt, + LicenseID: "test-license-id", + InstanceID: "test-instance-id", + ClusterID: "test-cluster-id", + UserAgent: "test-user-agent", + AppStatus: "ready", + ResourceStates: "[]", + K8sVersion: "1.29.0", + K8sDistribution: "test-distribution", + DownstreamChannelID: "test-channel-id", + DownstreamChannelName: "test-channel-name", + DownstreamChannelSequence: 1, + } +} + +func createLargeTestInstanceEvent(seed int64) InstanceReportEvent { + r := rand.New(rand.NewSource(seed)) + + sizeInBytes := 100 * 1024 // 100KB + + charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" + + randomBytes := make([]byte, sizeInBytes) + for i := 0; i < sizeInBytes; i++ { + randomBytes[i] = charset[r.Intn(len(charset))] + } + + return InstanceReportEvent{ + ResourceStates: string(randomBytes), // can use any field here + } +} + +func getTestInstanceReportWithMaxEvents() *InstanceReport { + report := &InstanceReport{ + Events: []InstanceReportEvent{}, + } + for i := 0; i < report.GetEventLimit(); i++ { + report.Events = append(report.Events, createTestInstanceEvent(int64(i))) + } + return report +} + +func getTestInstanceReportWithMaxSize() (*InstanceReport, error) { + report := &InstanceReport{ + Events: []InstanceReportEvent{}, + } + + encoded, err := EncodeReport(report) + if err != nil { + return nil, errors.Wrap(err, "failed to encode instance report") + } + + for i := 0; len(encoded) <= report.GetSizeLimit(); i++ { + seed := int64(i) + event := createLargeTestInstanceEvent(seed) + eventSize := len(event.ResourceStates) + + if len(encoded)+eventSize > report.GetSizeLimit() { + break + } + + report.Events = append(report.Events, event) + + encoded, err = EncodeReport(report) + if err != nil { + return nil, errors.Wrap(err, "failed to encode instance report") + } + } + + return report, nil +} + +func createTestCustomAppMetricsEvent(reportedAt int64) CustomAppMetricsReportEvent { + return CustomAppMetricsReportEvent{ + ReportedAt: reportedAt, + LicenseID: "test-license-id", + InstanceID: "test-instance-id", + Data: map[string]interface{}{ + "key1_string": "val1", + "key2_int": 2, + "key3_float": 3.5, + "key4_numeric_string": "4.0", + "key5_bool": true, + }, + } +} + +func createLargeTestCustomAppMetricsEvent(seed int64) CustomAppMetricsReportEvent { + r := rand.New(rand.NewSource(seed)) + + sizeInBytes := 100 * 1024 // 100KB + + charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" + + randomBytes := make([]byte, sizeInBytes) + for i := 0; i < sizeInBytes; i++ { + randomBytes[i] = charset[r.Intn(len(charset))] + } + + return CustomAppMetricsReportEvent{ + Data: map[string]interface{}{ + "random_bytes": randomBytes, + }, + } +} + +func getTestCustomAppMetricsReportWithMaxEvents() *CustomAppMetricsReport { + report := &CustomAppMetricsReport{ + Events: []CustomAppMetricsReportEvent{}, + } + for i := 0; i < report.GetEventLimit(); i++ { + report.Events = append(report.Events, createTestCustomAppMetricsEvent(int64(i))) + } + return report +} + +func getTestCustomAppMetricsReportWithMaxSize() (*CustomAppMetricsReport, error) { + report := &CustomAppMetricsReport{ + Events: []CustomAppMetricsReportEvent{}, + } + + encoded, err := EncodeReport(report) + if err != nil { + return nil, errors.Wrap(err, "failed to encode custom app metrics report") + } + + for i := 0; len(encoded) <= report.GetSizeLimit(); i++ { + seed := int64(i) + event := createLargeTestCustomAppMetricsEvent(seed) + eventSize := len(event.Data["random_bytes"].([]byte)) + + if len(encoded)+eventSize > report.GetSizeLimit() { + break + } + + report.Events = append(report.Events, event) + + encoded, err = EncodeReport(report) + if err != nil { + return nil, errors.Wrap(err, "failed to encode custom app metrics report") + } + } + + return report, nil +} diff --git a/pkg/heartbeat/types/types.go b/pkg/report/types/types.go similarity index 61% rename from pkg/heartbeat/types/types.go rename to pkg/report/types/types.go index 76e83625..b88be41c 100644 --- a/pkg/heartbeat/types/types.go +++ b/pkg/report/types/types.go @@ -23,7 +23,7 @@ const ( Tanzu ) -type HeartbeatInfo struct { +type InstanceData struct { InstanceID string `json:"instance_id"` ClusterID string `json:"cluster_id"` ChannelID string `json:"channel_id"` @@ -67,22 +67,3 @@ func (d Distribution) String() string { } return "unknown" } - -type InstanceReport struct { - Events []InstanceReportEvent `json:"events"` -} - -type InstanceReportEvent struct { - ReportedAt int64 `json:"reported_at"` - LicenseID string `json:"license_id"` - InstanceID string `json:"instance_id"` - ClusterID string `json:"cluster_id"` - UserAgent string `json:"user_agent"` - AppStatus string `json:"app_status,omitempty"` - ResourceStates string `json:"resource_states,omitempty"` - K8sVersion string `json:"k8s_version"` - K8sDistribution string `json:"k8s_distribution,omitempty"` - DownstreamChannelID string `json:"downstream_channel_id,omitempty"` - DownstreamChannelSequence int64 `json:"downstream_channel_sequence"` - DownstreamChannelName string `json:"downstream_channel_name,omitempty"` -} diff --git a/pkg/heartbeat/util.go b/pkg/report/util.go similarity index 68% rename from pkg/heartbeat/util.go rename to pkg/report/util.go index 7e2a89ce..04eca267 100644 --- a/pkg/heartbeat/util.go +++ b/pkg/report/util.go @@ -1,4 +1,4 @@ -package heartbeat +package report import ( "context" @@ -9,17 +9,17 @@ import ( "github.com/pkg/errors" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" - "github.com/replicatedhq/replicated-sdk/pkg/heartbeat/types" "github.com/replicatedhq/replicated-sdk/pkg/logger" + "github.com/replicatedhq/replicated-sdk/pkg/report/types" "github.com/replicatedhq/replicated-sdk/pkg/util" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" ) -func InjectHeartbeatInfoPayload(reqPayload map[string]interface{}, heartbeatInfo *types.HeartbeatInfo) error { - payload, err := GetHeartbeatInfoPayload(heartbeatInfo) +func InjectInstanceDataPayload(reqPayload map[string]interface{}, instanceData *types.InstanceData) error { + payload, err := GetInstanceDataPayload(instanceData) if err != nil { - return errors.Wrap(err, "failed to get heartbeat info payload") + return errors.Wrap(err, "failed to get instance data payload") } for key, value := range payload { @@ -29,16 +29,16 @@ func InjectHeartbeatInfoPayload(reqPayload map[string]interface{}, heartbeatInfo return nil } -func GetHeartbeatInfoPayload(heartbeatInfo *types.HeartbeatInfo) (map[string]interface{}, error) { +func GetInstanceDataPayload(instanceData *types.InstanceData) (map[string]interface{}, error) { payload := make(map[string]interface{}) - if heartbeatInfo == nil { + if instanceData == nil { return payload, nil } // only include resource states if they have been initialized - if heartbeatInfo.ResourceStates != nil { - marshalledRS, err := json.Marshal(heartbeatInfo.ResourceStates) + if instanceData.ResourceStates != nil { + marshalledRS, err := json.Marshal(instanceData.ResourceStates) if err != nil { return nil, errors.Wrap(err, "failed to marshal resource states") } @@ -48,40 +48,40 @@ func GetHeartbeatInfoPayload(heartbeatInfo *types.HeartbeatInfo) (map[string]int return payload, nil } -func InjectHeartbeatInfoHeaders(req *http.Request, heartbeatInfo *types.HeartbeatInfo) { - headers := GetHeartbeatInfoHeaders(heartbeatInfo) +func InjectInstanceDataHeaders(req *http.Request, instanceData *types.InstanceData) { + headers := GetInstanceDataHeaders(instanceData) for key, value := range headers { req.Header.Set(key, value) } } -func GetHeartbeatInfoHeaders(heartbeatInfo *types.HeartbeatInfo) map[string]string { +func GetInstanceDataHeaders(instanceData *types.InstanceData) map[string]string { headers := make(map[string]string) - if heartbeatInfo == nil { + if instanceData == nil { return headers } - headers["X-Replicated-K8sVersion"] = heartbeatInfo.K8sVersion - headers["X-Replicated-ClusterID"] = heartbeatInfo.ClusterID - headers["X-Replicated-InstanceID"] = heartbeatInfo.InstanceID + headers["X-Replicated-K8sVersion"] = instanceData.K8sVersion + headers["X-Replicated-ClusterID"] = instanceData.ClusterID + headers["X-Replicated-InstanceID"] = instanceData.InstanceID // only include app status related information if it's been initialized - if heartbeatInfo.AppStatus != "" { - headers["X-Replicated-AppStatus"] = heartbeatInfo.AppStatus + if instanceData.AppStatus != "" { + headers["X-Replicated-AppStatus"] = instanceData.AppStatus } - if heartbeatInfo.ChannelID != "" { - headers["X-Replicated-DownstreamChannelID"] = heartbeatInfo.ChannelID - } else if heartbeatInfo.ChannelName != "" { - headers["X-Replicated-DownstreamChannelName"] = heartbeatInfo.ChannelName + if instanceData.ChannelID != "" { + headers["X-Replicated-DownstreamChannelID"] = instanceData.ChannelID + } else if instanceData.ChannelName != "" { + headers["X-Replicated-DownstreamChannelName"] = instanceData.ChannelName } - headers["X-Replicated-DownstreamChannelSequence"] = strconv.FormatInt(heartbeatInfo.ChannelSequence, 10) + headers["X-Replicated-DownstreamChannelSequence"] = strconv.FormatInt(instanceData.ChannelSequence, 10) - if heartbeatInfo.K8sDistribution != "" { - headers["X-Replicated-K8sDistribution"] = heartbeatInfo.K8sDistribution + if instanceData.K8sDistribution != "" { + headers["X-Replicated-K8sDistribution"] = instanceData.K8sDistribution } return headers diff --git a/pkg/heartbeat/util_test.go b/pkg/report/util_test.go similarity index 91% rename from pkg/heartbeat/util_test.go rename to pkg/report/util_test.go index 91ad4dc9..f8d451e0 100644 --- a/pkg/heartbeat/util_test.go +++ b/pkg/report/util_test.go @@ -1,18 +1,18 @@ -package heartbeat +package report import ( "testing" appstatetypes "github.com/replicatedhq/replicated-sdk/pkg/appstate/types" - "github.com/replicatedhq/replicated-sdk/pkg/heartbeat/types" "github.com/replicatedhq/replicated-sdk/pkg/k8sutil" + "github.com/replicatedhq/replicated-sdk/pkg/report/types" "github.com/replicatedhq/replicated-sdk/pkg/util" "github.com/stretchr/testify/assert" "k8s.io/client-go/kubernetes/fake" ) -func TestInjectHeartbeatInfoPayload(t *testing.T) { - heartbeatInfo := &types.HeartbeatInfo{ +func TestInjectInstanceDataPayload(t *testing.T) { + instanceData := &types.InstanceData{ AppStatus: "ready", ResourceStates: appstatetypes.ResourceStates{ { @@ -32,7 +32,7 @@ func TestInjectHeartbeatInfoPayload(t *testing.T) { reqPayload := make(map[string]interface{}) - err := InjectHeartbeatInfoPayload(reqPayload, heartbeatInfo) + err := InjectInstanceDataPayload(reqPayload, instanceData) assert.NoError(t, err) expectedPayload := map[string]interface{}{ @@ -40,10 +40,10 @@ func TestInjectHeartbeatInfoPayload(t *testing.T) { } assert.Equal(t, expectedPayload, reqPayload) - // test nil heartbeat info + // test nil instance data reqPayload = make(map[string]interface{}) - err = InjectHeartbeatInfoPayload(reqPayload, nil) + err = InjectInstanceDataPayload(reqPayload, nil) assert.NoError(t, err) expectedPayload = map[string]interface{}{} @@ -52,7 +52,7 @@ func TestInjectHeartbeatInfoPayload(t *testing.T) { // test empty app status reqPayload = make(map[string]interface{}) - err = InjectHeartbeatInfoPayload(reqPayload, &types.HeartbeatInfo{ + err = InjectInstanceDataPayload(reqPayload, &types.InstanceData{ AppStatus: "", }) assert.NoError(t, err) @@ -61,8 +61,8 @@ func TestInjectHeartbeatInfoPayload(t *testing.T) { assert.Equal(t, expectedPayload, reqPayload) } -func TestGetHeartbeatInfoHeaders(t *testing.T) { - heartbeatInfo := &types.HeartbeatInfo{ +func TestGetInstanceDataHeaders(t *testing.T) { + instanceData := &types.InstanceData{ AppStatus: "ready", ClusterID: "cluster-123", InstanceID: "instance-456", @@ -72,7 +72,7 @@ func TestGetHeartbeatInfoHeaders(t *testing.T) { K8sDistribution: "k3s", } - headers := GetHeartbeatInfoHeaders(heartbeatInfo) + headers := GetInstanceDataHeaders(instanceData) expectedHeaders := map[string]string{ "X-Replicated-K8sVersion": "v1.20.2+k3s1", @@ -85,12 +85,12 @@ func TestGetHeartbeatInfoHeaders(t *testing.T) { } assert.Equal(t, expectedHeaders, headers) - // nil heartbeat info - nilHeaders := GetHeartbeatInfoHeaders(nil) + // nil instance data + nilHeaders := GetInstanceDataHeaders(nil) assert.Empty(t, nilHeaders) // empty app status - emptyAppStatusHeaders := GetHeartbeatInfoHeaders(&types.HeartbeatInfo{ + emptyAppStatusHeaders := GetInstanceDataHeaders(&types.InstanceData{ AppStatus: "", }) _, appStatusOk := emptyAppStatusHeaders["X-Replicated-AppStatus"] diff --git a/pkg/upstream/replicated.go b/pkg/upstream/replicated.go index 272dca7a..59ce8e91 100644 --- a/pkg/upstream/replicated.go +++ b/pkg/upstream/replicated.go @@ -11,7 +11,7 @@ import ( "github.com/pkg/errors" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" - "github.com/replicatedhq/replicated-sdk/pkg/heartbeat" + "github.com/replicatedhq/replicated-sdk/pkg/report" "github.com/replicatedhq/replicated-sdk/pkg/store" types "github.com/replicatedhq/replicated-sdk/pkg/upstream/types" "github.com/replicatedhq/replicated-sdk/pkg/util" @@ -48,12 +48,12 @@ 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()) - heartbeatInfo := heartbeat.GetHeartbeatInfo(sdkStore) + instanceData := report.GetInstanceData(sdkStore) // build the request body reqPayload := map[string]interface{}{} - if err := heartbeat.InjectHeartbeatInfoPayload(reqPayload, heartbeatInfo); err != nil { - return nil, errors.Wrap(err, "failed to inject heartbeat info payload") + if err := report.InjectInstanceDataPayload(reqPayload, instanceData); err != nil { + return nil, errors.Wrap(err, "failed to inject instance data payload") } reqBody, err := json.Marshal(reqPayload) if err != nil { @@ -67,7 +67,7 @@ func GetUpdates(sdkStore store.Store, license *kotsv1beta1.License, currentCurso req.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", license.Spec.LicenseID, license.Spec.LicenseID))))) req.Header.Set("Content-Type", "application/json") - heartbeat.InjectHeartbeatInfoHeaders(req, heartbeatInfo) + report.InjectInstanceDataHeaders(req, instanceData) resp, err := http.DefaultClient.Do(req) if err != nil {