Skip to content

Commit f7a138a

Browse files
author
Siva M
authored
feat[sc-100944]: Support upserting and deleting custom metrics (#191)
* sync custom metrics
1 parent b4ed1b1 commit f7a138a

File tree

14 files changed

+285
-43
lines changed

14 files changed

+285
-43
lines changed

pact/custom_metrics_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import (
1313
"github.com/pact-foundation/pact-go/dsl"
1414
"github.com/replicatedhq/kotskinds/apis/kots/v1beta1"
1515
"github.com/replicatedhq/replicated-sdk/pkg/handlers"
16+
"github.com/replicatedhq/replicated-sdk/pkg/k8sutil"
1617
"github.com/replicatedhq/replicated-sdk/pkg/store"
18+
"github.com/replicatedhq/replicated-sdk/pkg/util"
19+
"k8s.io/client-go/kubernetes/fake"
1720
)
1821

1922
func TestSendCustomAppMetrics(t *testing.T) {
@@ -63,6 +66,11 @@ func TestSendCustomAppMetrics(t *testing.T) {
6366
Status: http.StatusOK,
6467
})
6568
}
69+
fakeClientSet := fake.NewSimpleClientset(
70+
k8sutil.CreateTestDeployment(util.GetReplicatedDeploymentName(), "default", "1", map[string]string{"app": "replicated"}),
71+
k8sutil.CreateTestReplicaSet("replicated-sdk-instance-replicaset", "default", "1"),
72+
k8sutil.CreateTestPod("replicated-sdk-instance-pod", "default", "replicated-sdk-instance-replicaset", map[string]string{"app": "replicated"}),
73+
)
6674
t.Run("Send valid custom app metrics", func(t *testing.T) {
6775
pactInteraction()
6876

@@ -72,11 +80,13 @@ func TestSendCustomAppMetrics(t *testing.T) {
7280
ReplicatedAppEndpoint: license.Spec.Endpoint,
7381
ChannelID: license.Spec.ChannelID,
7482
ChannelSequence: channelSequence,
83+
Namespace: "default",
7584
}
7685
store.InitInMemory(storeOptions)
7786
defer store.SetStore(nil)
7887

7988
if err := pact.Verify(func() error {
89+
handlers.SetTestClientSet(fakeClientSet)
8090
handlers.SendCustomAppMetrics(clientWriter, clientRequest)
8191
if clientWriter.Code != http.StatusOK {
8292
return fmt.Errorf("expected status code %d but got %d", http.StatusOK, clientWriter.Code)

pkg/apiserver/server.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ func Start(params APIServerParams) {
6666
r.HandleFunc("/api/v1/app/info", handlers.GetCurrentAppInfo).Methods("GET")
6767
r.HandleFunc("/api/v1/app/updates", handlers.GetAppUpdates).Methods("GET")
6868
r.HandleFunc("/api/v1/app/history", handlers.GetAppHistory).Methods("GET")
69-
r.HandleFunc("/api/v1/app/custom-metrics", handlers.SendCustomAppMetrics).Methods("POST")
69+
r.HandleFunc("/api/v1/app/custom-metrics", handlers.SendCustomAppMetrics).Methods("POST", "PATCH")
70+
r.HandleFunc("/api/v1/app/custom-metrics/{key}", handlers.DeleteCustomAppMetricsKey).Methods("DELETE")
7071
r.HandleFunc("/api/v1/app/instance-tags", handlers.SendAppInstanceTags).Methods("POST")
7172

7273
// integration

pkg/handlers/app.go

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"strings"
1010
"time"
1111

12+
"github.com/gorilla/mux"
1213
"github.com/pkg/errors"
1314
appstatetypes "github.com/replicatedhq/replicated-sdk/pkg/appstate/types"
1415
"github.com/replicatedhq/replicated-sdk/pkg/config"
@@ -18,15 +19,16 @@ import (
1819
"github.com/replicatedhq/replicated-sdk/pkg/k8sutil"
1920
sdklicense "github.com/replicatedhq/replicated-sdk/pkg/license"
2021
"github.com/replicatedhq/replicated-sdk/pkg/logger"
22+
"github.com/replicatedhq/replicated-sdk/pkg/meta"
23+
"github.com/replicatedhq/replicated-sdk/pkg/meta/types"
2124
"github.com/replicatedhq/replicated-sdk/pkg/report"
2225
"github.com/replicatedhq/replicated-sdk/pkg/store"
23-
"github.com/replicatedhq/replicated-sdk/pkg/tags"
24-
"github.com/replicatedhq/replicated-sdk/pkg/tags/types"
2526
"github.com/replicatedhq/replicated-sdk/pkg/upstream"
2627
upstreamtypes "github.com/replicatedhq/replicated-sdk/pkg/upstream/types"
2728
"github.com/replicatedhq/replicated-sdk/pkg/util"
2829
helmrelease "helm.sh/helm/v3/pkg/release"
2930
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
31+
"k8s.io/client-go/kubernetes"
3032
"k8s.io/client-go/kubernetes/scheme"
3133
)
3234

@@ -352,6 +354,12 @@ func mockReleaseToAppRelease(mockRelease integrationtypes.MockRelease) AppReleas
352354
return appRelease
353355
}
354356

357+
var testClientSet kubernetes.Interface
358+
359+
func SetTestClientSet(clientset kubernetes.Interface) {
360+
testClientSet = clientset
361+
}
362+
355363
func SendCustomAppMetrics(w http.ResponseWriter, r *http.Request) {
356364
request := SendCustomAppMetricsRequest{}
357365
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
@@ -366,22 +374,60 @@ func SendCustomAppMetrics(w http.ResponseWriter, r *http.Request) {
366374
return
367375
}
368376

377+
var clientset kubernetes.Interface
378+
if testClientSet != nil {
379+
clientset = testClientSet
380+
} else {
381+
var err error
382+
clientset, err = k8sutil.GetClientset()
383+
if err != nil {
384+
logger.Error(errors.Wrap(err, "failed to get clientset"))
385+
w.WriteHeader(http.StatusInternalServerError)
386+
return
387+
}
388+
}
389+
390+
overwrite := true
391+
if r.Method == http.MethodPatch {
392+
overwrite = false
393+
}
394+
395+
if err := report.SendCustomAppMetrics(clientset, store.GetStore(), request.Data, overwrite); err != nil {
396+
logger.Error(errors.Wrap(err, "set application data"))
397+
w.WriteHeader(http.StatusBadRequest)
398+
return
399+
}
400+
401+
JSON(w, http.StatusOK, "")
402+
}
403+
404+
func DeleteCustomAppMetricsKey(w http.ResponseWriter, r *http.Request) {
405+
key, ok := mux.Vars(r)["key"]
406+
407+
if !ok || key == "" {
408+
w.WriteHeader(http.StatusBadRequest)
409+
fmt.Fprintf(w, "key cannot be empty")
410+
logger.Errorf("cannot delete custom metrics key - key cannot be empty")
411+
return
412+
}
413+
369414
clientset, err := k8sutil.GetClientset()
370415
if err != nil {
371416
logger.Error(errors.Wrap(err, "failed to get clientset"))
372417
w.WriteHeader(http.StatusInternalServerError)
373418
return
374419
}
375420

376-
if err := report.SendCustomAppMetrics(clientset, store.GetStore(), request.Data); err != nil {
377-
logger.Error(errors.Wrap(err, "set application data"))
421+
data := map[string]interface{}{key: nil}
422+
423+
if err := report.SendCustomAppMetrics(clientset, store.GetStore(), data, false); err != nil {
424+
logger.Error(errors.Wrapf(err, "failed to delete custom merics key: %s", key))
378425
w.WriteHeader(http.StatusBadRequest)
379426
return
380427
}
381428

382-
JSON(w, http.StatusOK, "")
429+
JSON(w, http.StatusNoContent, "")
383430
}
384-
385431
func validateCustomAppMetricsData(data CustomAppMetricsData) error {
386432
if len(data) == 0 {
387433
return errors.New("no data provided")
@@ -429,7 +475,7 @@ func SendAppInstanceTags(w http.ResponseWriter, r *http.Request) {
429475
return
430476
}
431477

432-
if err := tags.Save(r.Context(), clientset, store.GetStore().GetNamespace(), request.Data); err != nil {
478+
if err := meta.SaveInstanceTag(r.Context(), clientset, store.GetStore().GetNamespace(), request.Data); err != nil {
433479
logger.Errorf("failed to save instance tags: %v", err)
434480
w.WriteHeader(http.StatusInternalServerError)
435481
return

pkg/k8sutil/fake.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ func CreateTestDeployment(name string, namespace string, revision string, matchL
1414
Annotations: map[string]string{
1515
"deployment.kubernetes.io/revision": revision,
1616
},
17+
UID: "test-deployment-uid",
1718
},
1819
Spec: appsv1.DeploymentSpec{
1920
Selector: &metav1.LabelSelector{
@@ -31,13 +32,15 @@ func CreateTestReplicaSet(name string, namespace string, revision string) *appsv
3132
Annotations: map[string]string{
3233
"deployment.kubernetes.io/revision": revision,
3334
},
35+
UID: "test-deployment-uid",
3436
},
3537
}
3638
}
3739

3840
func CreateTestPod(name string, namespace string, replicaSetName string, labels map[string]string) *corev1.Pod {
3941
return &corev1.Pod{
4042
ObjectMeta: metav1.ObjectMeta{
43+
UID: "test-deployment-uid",
4144
Name: name,
4245
Namespace: namespace,
4346
Labels: labels,

pkg/meta/instance_tags.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package meta
2+
3+
import (
4+
"context"
5+
6+
"github.com/pkg/errors"
7+
"github.com/replicatedhq/replicated-sdk/pkg/meta/types"
8+
"k8s.io/client-go/kubernetes"
9+
)
10+
11+
const (
12+
instanceTagSecretKey replicatedMetadataSecretKey = "instance-tag-data"
13+
)
14+
15+
func SaveInstanceTag(ctx context.Context, clientset kubernetes.Interface, namespace string, tdata types.InstanceTagData) error {
16+
return save(ctx, clientset, namespace, instanceTagSecretKey, tdata)
17+
}
18+
19+
func GetInstanceTag(ctx context.Context, clientset kubernetes.Interface, namespace string) (*types.InstanceTagData, error) {
20+
t := types.InstanceTagData{}
21+
22+
if err := get(ctx, clientset, namespace, instanceTagSecretKey, &t); err != nil {
23+
return nil, errors.Wrapf(err, "failed to get instance tag data")
24+
}
25+
26+
return &t, nil
27+
28+
}

pkg/meta/latest_custom_metrics.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package meta
2+
3+
import (
4+
"context"
5+
"maps"
6+
7+
"github.com/pkg/errors"
8+
"k8s.io/client-go/kubernetes"
9+
)
10+
11+
const (
12+
customMetricsSecretKey replicatedMetadataSecretKey = "latest-custom-metrics"
13+
)
14+
15+
func SyncCustomAppMetrics(ctx context.Context, clientset kubernetes.Interface, namespace string, inboundMetrics map[string]interface{}, overwrite bool) (map[string]interface{}, error) {
16+
existing := map[string]interface{}{}
17+
18+
err := get(ctx, clientset, namespace, customMetricsSecretKey, &existing)
19+
if err != nil && errors.Cause(err) != ErrReplicatedMetadataSecretNotFound {
20+
return nil, errors.Wrapf(err, "failed to get custom metrics data")
21+
}
22+
23+
modified := mergeCustomAppMetrics(existing, inboundMetrics, overwrite)
24+
25+
if err := save(ctx, clientset, namespace, customMetricsSecretKey, modified); err != nil {
26+
return nil, errors.Wrap(err, "failed to save custom metrics")
27+
}
28+
29+
return modified, nil
30+
}
31+
32+
func mergeCustomAppMetrics(existingMetrics map[string]interface{}, inboundMetrics map[string]interface{}, overwrite bool) map[string]interface{} {
33+
if existingMetrics == nil {
34+
existingMetrics = map[string]interface{}{}
35+
}
36+
37+
if inboundMetrics == nil {
38+
inboundMetrics = map[string]interface{}{}
39+
}
40+
41+
if overwrite {
42+
return inboundMetrics
43+
}
44+
45+
if len(inboundMetrics) == 0 || maps.Equal(existingMetrics, inboundMetrics) {
46+
return existingMetrics
47+
}
48+
49+
for k, v := range inboundMetrics {
50+
if v == nil {
51+
delete(existingMetrics, k)
52+
continue
53+
}
54+
55+
existingMetrics[k] = v
56+
}
57+
58+
return existingMetrics
59+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package meta
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func Test_mergeCustomAppMetrics(tst *testing.T) {
10+
tests := []struct {
11+
name string
12+
existingMetrics map[string]interface{}
13+
inboundMetrics map[string]interface{}
14+
overwrite bool
15+
assertFn func(*testing.T, map[string]interface{})
16+
}{
17+
{
18+
name: "should return empty if both are empty",
19+
existingMetrics: map[string]interface{}{},
20+
inboundMetrics: map[string]interface{}{},
21+
overwrite: false,
22+
assertFn: func(t *testing.T, actual map[string]interface{}) {
23+
assert.NotNil(t, actual)
24+
assert.Empty(t, actual)
25+
},
26+
},
27+
{
28+
name: "should tolerate nil value on existingMetrics",
29+
existingMetrics: nil,
30+
inboundMetrics: map[string]interface{}{"numProjects": 10},
31+
overwrite: false,
32+
assertFn: func(t *testing.T, actual map[string]interface{}) {
33+
expected := map[string]interface{}{"numProjects": 10}
34+
assert.Equal(t, expected, actual)
35+
},
36+
},
37+
{
38+
name: "should tolerate nil value on inboundMetrics",
39+
existingMetrics: map[string]interface{}{"numProjects": 10},
40+
inboundMetrics: nil,
41+
overwrite: false,
42+
assertFn: func(t *testing.T, actual map[string]interface{}) {
43+
expected := map[string]interface{}{"numProjects": 10}
44+
assert.Equal(t, expected, actual)
45+
},
46+
},
47+
{
48+
name: "should return inboundMetrics when overwrite is true",
49+
existingMetrics: map[string]interface{}{"numProjects": 10, "token": "1234"},
50+
inboundMetrics: map[string]interface{}{"newProjects": 100, "newToken": 10},
51+
overwrite: true, // overwrites existing metric data with inbound metrics data
52+
assertFn: func(t *testing.T, actual map[string]interface{}) {
53+
expected := map[string]interface{}{"newProjects": 100, "newToken": 10}
54+
assert.Equal(t, expected, actual)
55+
},
56+
},
57+
{
58+
name: "should return merged data when overwrite is false",
59+
existingMetrics: map[string]interface{}{"numProjects": 10, "token": "1234"},
60+
inboundMetrics: map[string]interface{}{"numProjects": 66666, "numPeople": 100},
61+
overwrite: false,
62+
assertFn: func(t *testing.T, actual map[string]interface{}) {
63+
expected := map[string]interface{}{"numPeople": 100, "numProjects": 66666, "token": "1234"}
64+
assert.Equal(t, expected, actual)
65+
},
66+
},
67+
{
68+
name: "should delete existing metric key when the corresponding inboundMetrics value is nil",
69+
existingMetrics: map[string]interface{}{"numProjects": 10, "token": "1234"},
70+
inboundMetrics: map[string]interface{}{"numProjects": nil}, // delete numProjects
71+
overwrite: false,
72+
assertFn: func(t *testing.T, actual map[string]interface{}) {
73+
expected := map[string]interface{}{"token": "1234"}
74+
assert.Equal(t, expected, actual)
75+
},
76+
},
77+
}
78+
79+
for _, tt := range tests {
80+
m := mergeCustomAppMetrics(tt.existingMetrics, tt.inboundMetrics, tt.overwrite)
81+
tt.assertFn(tst, m)
82+
}
83+
}

0 commit comments

Comments
 (0)