diff --git a/.github/actions/validate-endpoints/action.yml b/.github/actions/validate-endpoints/action.yml index 96fc6a30..a110879e 100644 --- a/.github/actions/validate-endpoints/action.yml +++ b/.github/actions/validate-endpoints/action.yml @@ -179,3 +179,5 @@ runs: echo "Expected 5 events, but found $numOfEvents" exit 1 fi + + diff --git a/Makefile b/Makefile index 53a13800..ac04ed0b 100644 --- a/Makefile +++ b/Makefile @@ -58,7 +58,7 @@ vet: .PHONY: build-ttl.sh build-ttl.sh: - docker build -t ttl.sh/${USER}/replicated-sdk:24h deploy/Dockerfile + docker buildx build . -t ttl.sh/${USER}/replicated-sdk:24h -f deploy/Dockerfile docker push ttl.sh/${USER}/replicated-sdk:24h make -C chart build-ttl.sh diff --git a/chart/templates/replicated-role.yaml b/chart/templates/replicated-role.yaml index ab97bc5f..452f680a 100644 --- a/chart/templates/replicated-role.yaml +++ b/chart/templates/replicated-role.yaml @@ -31,4 +31,5 @@ rules: - {{ include "replicated.secretName" . }} - replicated-instance-report - replicated-custom-app-metrics-report + - replicated-meta-data {{ end }} \ No newline at end of file diff --git a/chart/templates/replicated-supportbundle.yaml b/chart/templates/replicated-supportbundle.yaml index c801a099..0d83259f 100644 --- a/chart/templates/replicated-supportbundle.yaml +++ b/chart/templates/replicated-supportbundle.yaml @@ -49,6 +49,11 @@ stringData: name: replicated-custom-app-metrics-report includeValue: true key: report + - secret: + namespace: {{ include "replicated.namespace" . }} + name: replicated-meta-data + includeValue: true + key: instance-tag-data analyzers: - jsonCompare: checkName: Replicated SDK App Status diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 0d50ab77..c88f5c27 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -5,19 +5,25 @@ ENTRYPOINT /usr/bin/go FROM local_go as builder +ENV GOMODCACHE=/.cache/gomod-cache +ENV GOCACHE=/.cache/go-cache + ENV PROJECTPATH=/go/src/github.com/replicatedhq/replicated-sdk WORKDIR $PROJECTPATH COPY Makefile.build.mk ./ COPY Makefile ./ COPY go.mod go.sum ./ +RUN --mount=type=cache,target=${GOMODCACHE} go mod download COPY cmd ./cmd COPY pkg ./pkg ARG git_tag ENV GIT_TAG=${git_tag} -RUN make build && mv ./bin/replicated /replicated +RUN --mount=type=cache,target=${GOMODCACHE} \ + --mount=type=cache,target=${GOCACHE} \ + make build && mv ./bin/replicated /replicated FROM cgr.dev/chainguard/static:latest diff --git a/pact/instance_test.go b/pact/instance_test.go index 8ce9c0c6..6f6a5943 100644 --- a/pact/instance_test.go +++ b/pact/instance_test.go @@ -45,7 +45,7 @@ func TestSendInstanceData(t *testing.T) { Endpoint: fmt.Sprintf("http://%s:%d", pact.Host, pact.Server.Port), }, }) - mockStore.EXPECT().GetNamespace().Return("replicated-sdk-instance-namespace") + mockStore.EXPECT().GetNamespace().Times(2).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") @@ -96,7 +96,7 @@ func TestSendInstanceData(t *testing.T) { Endpoint: fmt.Sprintf("http://%s:%d", pact.Host, pact.Server.Port), }, }) - mockStore.EXPECT().GetNamespace().Return("replicated-sdk-instance-namespace") + mockStore.EXPECT().GetNamespace().Times(2).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") @@ -147,7 +147,7 @@ func TestSendInstanceData(t *testing.T) { Endpoint: fmt.Sprintf("http://%s:%d", pact.Host, pact.Server.Port), }, }) - mockStore.EXPECT().GetNamespace().Return("replicated-sdk-instance-namespace") + mockStore.EXPECT().GetNamespace().Times(2).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") @@ -198,7 +198,7 @@ func TestSendInstanceData(t *testing.T) { Endpoint: fmt.Sprintf("http://%s:%d", pact.Host, pact.Server.Port), }, }) - mockStore.EXPECT().GetNamespace().Return("replicated-sdk-instance-namespace") + mockStore.EXPECT().GetNamespace().Times(2).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") diff --git a/pkg/apiserver/server.go b/pkg/apiserver/server.go index 8888b47d..06e46222 100644 --- a/pkg/apiserver/server.go +++ b/pkg/apiserver/server.go @@ -67,6 +67,7 @@ func Start(params APIServerParams) { r.HandleFunc("/api/v1/app/updates", handlers.GetAppUpdates).Methods("GET") r.HandleFunc("/api/v1/app/history", handlers.GetAppHistory).Methods("GET") r.HandleFunc("/api/v1/app/custom-metrics", handlers.SendCustomAppMetrics).Methods("POST") + r.HandleFunc("/api/v1/app/instance-tags", handlers.SendAppInstanceTags).Methods("POST") // integration r.HandleFunc("/api/v1/integration/mock-data", handlers.EnforceMockAccess(handlers.PostIntegrationMockData)).Methods("POST") diff --git a/pkg/handlers/app.go b/pkg/handlers/app.go index c81bcda6..ef9023f4 100644 --- a/pkg/handlers/app.go +++ b/pkg/handlers/app.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "fmt" "net/http" "reflect" "sort" @@ -19,6 +20,8 @@ import ( "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/tags" + "github.com/replicatedhq/replicated-sdk/pkg/tags/types" "github.com/replicatedhq/replicated-sdk/pkg/upstream" upstreamtypes "github.com/replicatedhq/replicated-sdk/pkg/upstream/types" "github.com/replicatedhq/replicated-sdk/pkg/util" @@ -55,6 +58,10 @@ type SendCustomAppMetricsRequest struct { type CustomAppMetricsData map[string]interface{} +type SendAppInstanceTagsRequest struct { + Data types.InstanceTagData `json:"data"` +} + func GetCurrentAppInfo(w http.ResponseWriter, r *http.Request) { clientset, err := k8sutil.GetClientset() if err != nil { @@ -383,3 +390,41 @@ func validateCustomAppMetricsData(data CustomAppMetricsData) error { return nil } + +func SendAppInstanceTags(w http.ResponseWriter, r *http.Request) { + request := SendAppInstanceTagsRequest{} + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t, ok := err.(*json.UnmarshalTypeError) + if ok { + logger.Errorf("failed to decode instance-tag request: %s value is not a string", t.Field) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "%v not supported, only string values are allowed on instance-tags", t.Value) + return + } + + logger.Error(errors.Wrap(err, "decode request")) + w.WriteHeader(http.StatusInternalServerError) + return + } + + clientset, err := k8sutil.GetClientset() + if err != nil { + logger.Error(errors.Wrap(err, "failed to get clientset")) + w.WriteHeader(http.StatusInternalServerError) + return + } + + if err := tags.Save(r.Context(), clientset, store.GetStore().GetNamespace(), request.Data); err != nil { + logger.Errorf("failed to save instance tags: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + if err := report.SendInstanceData(clientset, store.GetStore()); err != nil { + logger.Errorf("failed to send instance data: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + JSON(w, http.StatusOK, "") +} diff --git a/pkg/report/instance.go b/pkg/report/instance.go index 8587a858..92fca726 100644 --- a/pkg/report/instance.go +++ b/pkg/report/instance.go @@ -2,6 +2,7 @@ package report import ( "bytes" + "context" "encoding/base64" "encoding/json" "fmt" @@ -16,6 +17,7 @@ import ( "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/tags" "github.com/replicatedhq/replicated-sdk/pkg/util" "k8s.io/client-go/kubernetes" ) @@ -72,6 +74,12 @@ func SendAirgapInstanceData(clientset kubernetes.Interface, namespace string, li event.ResourceStates = string(marshalledRS) } + marshalledTags, err := json.Marshal(instanceData.Tags) + if err != nil { + return errors.Wrap(err, "failed to marshal tags") + } + event.Tags = string(marshalledTags) + report := &InstanceReport{ Events: []InstanceReportEvent{event}, } @@ -141,6 +149,12 @@ func GetInstanceData(sdkStore store.Store) *types.InstanceData { if distribution := GetDistribution(clientset); distribution != types.UnknownDistribution { r.K8sDistribution = distribution.String() } + + if tdata, err := tags.Get(context.TODO(), clientset, sdkStore.GetNamespace()); err != nil { + logger.Debugf("failed to get instance tag data: %v", err.Error()) + } else { + r.Tags = *tdata + } } return &r diff --git a/pkg/report/instance_report.go b/pkg/report/instance_report.go index 0bba0b42..7109d667 100644 --- a/pkg/report/instance_report.go +++ b/pkg/report/instance_report.go @@ -26,6 +26,7 @@ type InstanceReportEvent struct { DownstreamChannelID string `json:"downstream_channel_id,omitempty"` DownstreamChannelSequence int64 `json:"downstream_channel_sequence"` DownstreamChannelName string `json:"downstream_channel_name,omitempty"` + Tags string `json:"tags"` } func (r *InstanceReport) GetType() ReportType { diff --git a/pkg/report/instance_test.go b/pkg/report/instance_test.go index 4d7aaef3..90ccb337 100644 --- a/pkg/report/instance_test.go +++ b/pkg/report/instance_test.go @@ -65,7 +65,7 @@ func Test_SendInstanceData(t *testing.T) { Endpoint: mockServer.URL, }, }) - mockStore.EXPECT().GetNamespace().Return("test-namespace") + mockStore.EXPECT().GetNamespace().Times(2).Return("test-namespace") mockStore.EXPECT().GetReplicatedID().Return("test-cluster-id") mockStore.EXPECT().GetAppID().Return("test-app") mockStore.EXPECT().GetChannelID().Return("test-app-nightly") @@ -101,7 +101,7 @@ func Test_SendInstanceData(t *testing.T) { Endpoint: mockServer.URL, }, }) - mockStore.EXPECT().GetNamespace().Times(2).Return("test-namespace") + mockStore.EXPECT().GetNamespace().Times(3).Return("test-namespace") mockStore.EXPECT().GetReplicatedID().Return("test-cluster-id") mockStore.EXPECT().GetAppID().Return("test-app") mockStore.EXPECT().GetChannelID().Return("test-app-nightly") diff --git a/pkg/report/report_test.go b/pkg/report/report_test.go index c46d6886..1064c702 100644 --- a/pkg/report/report_test.go +++ b/pkg/report/report_test.go @@ -375,6 +375,7 @@ func createTestInstanceEvent(reportedAt int64) InstanceReportEvent { DownstreamChannelID: "test-channel-id", DownstreamChannelName: "test-channel-name", DownstreamChannelSequence: 1, + Tags: `{"force": false, "tags": {}}`, } } diff --git a/pkg/report/types/types.go b/pkg/report/types/types.go index b88be41c..a4a8c9aa 100644 --- a/pkg/report/types/types.go +++ b/pkg/report/types/types.go @@ -2,6 +2,7 @@ package types import ( appstatetypes "github.com/replicatedhq/replicated-sdk/pkg/appstate/types" + tagstypes "github.com/replicatedhq/replicated-sdk/pkg/tags/types" ) type Distribution int64 @@ -34,6 +35,7 @@ type InstanceData struct { ResourceStates appstatetypes.ResourceStates `json:"resource_states"` K8sVersion string `json:"k8s_version"` K8sDistribution string `json:"k8s_distribution"` + Tags tagstypes.InstanceTagData `json:"tags"` } func (d Distribution) String() string { diff --git a/pkg/report/util.go b/pkg/report/util.go index 04eca267..0cda844f 100644 --- a/pkg/report/util.go +++ b/pkg/report/util.go @@ -84,6 +84,15 @@ func GetInstanceDataHeaders(instanceData *types.InstanceData) map[string]string headers["X-Replicated-K8sDistribution"] = instanceData.K8sDistribution } + if !instanceData.Tags.IsEmpty() { + b64, err := instanceData.Tags.MarshalBase64() + if err != nil { + logger.Errorf("Failed to base64 encode instance tags into headers: %v: %v", instanceData.Tags, err) + } else { + headers["X-Replicated-InstanceTagData"] = string(b64) + } + } + return headers } diff --git a/pkg/report/util_test.go b/pkg/report/util_test.go index f8d451e0..98498d2a 100644 --- a/pkg/report/util_test.go +++ b/pkg/report/util_test.go @@ -6,6 +6,7 @@ import ( appstatetypes "github.com/replicatedhq/replicated-sdk/pkg/appstate/types" "github.com/replicatedhq/replicated-sdk/pkg/k8sutil" "github.com/replicatedhq/replicated-sdk/pkg/report/types" + tagstypes "github.com/replicatedhq/replicated-sdk/pkg/tags/types" "github.com/replicatedhq/replicated-sdk/pkg/util" "github.com/stretchr/testify/assert" "k8s.io/client-go/kubernetes/fake" @@ -70,6 +71,7 @@ func TestGetInstanceDataHeaders(t *testing.T) { ChannelSequence: 42, K8sVersion: "v1.20.2+k3s1", K8sDistribution: "k3s", + Tags: tagstypes.InstanceTagData{Force: true, Tags: map[string]string{"key": "value"}}, } headers := GetInstanceDataHeaders(instanceData) @@ -82,6 +84,7 @@ func TestGetInstanceDataHeaders(t *testing.T) { "X-Replicated-DownstreamChannelID": "channel-789", "X-Replicated-DownstreamChannelSequence": "42", "X-Replicated-K8sDistribution": "k3s", + "X-Replicated-InstanceTagData": "eyJmb3JjZSI6dHJ1ZSwidGFncyI6eyJrZXkiOiJ2YWx1ZSJ9fQ==", } assert.Equal(t, expectedHeaders, headers) diff --git a/pkg/tags/instance_tag_data.go b/pkg/tags/instance_tag_data.go new file mode 100644 index 00000000..bc4c8e4a --- /dev/null +++ b/pkg/tags/instance_tag_data.go @@ -0,0 +1,117 @@ +package tags + +import ( + "context" + "sync" + + "github.com/pkg/errors" + "github.com/replicatedhq/replicated-sdk/pkg/tags/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 ( + InstanceMetadataSecretName = "replicated-meta-data" + InstanceTagSecretKey = "instance-tag-data" +) + +var replicatedSecretLock = sync.Mutex{} + +func Save(ctx context.Context, clientset kubernetes.Interface, namespace string, tdata types.InstanceTagData) error { + + replicatedSecretLock.Lock() + defer replicatedSecretLock.Unlock() + + encodedTagData, err := tdata.MarshalBase64() + if err != nil { + return errors.Wrap(err, "failed to marshal instance tags") + } + + existingSecret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, InstanceMetadataSecretName, metav1.GetOptions{}) + if err != nil && !kuberneteserrors.IsNotFound(err) { + return errors.Wrap(err, "failed to get instance-tags secret") + } + + if kuberneteserrors.IsNotFound(err) { + 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: InstanceMetadataSecretName, + Namespace: namespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "apps/v1", + Kind: "Deployment", + Name: util.GetReplicatedDeploymentName(), + UID: uid, + }, + }, + }, + Data: map[string][]byte{ + InstanceTagSecretKey: encodedTagData, + }, + } + + _, err = clientset.CoreV1().Secrets(namespace).Create(ctx, 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{} + } + + existingSecret.Data[InstanceTagSecretKey] = encodedTagData + + _, err = clientset.CoreV1().Secrets(namespace).Update(ctx, existingSecret, metav1.UpdateOptions{}) + if err != nil { + return errors.Wrap(err, "failed to update instance-tags secret") + } + + return nil +} + +var ( + ErrInstanceTagDataIsEmpty = errors.New("instance tag data is empty") + ErrInstanceTagDataSecretNotFound = errors.New("instance tag secret not found") +) + +func Get(ctx context.Context, clientset kubernetes.Interface, namespace string) (*types.InstanceTagData, error) { + secret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, InstanceMetadataSecretName, metav1.GetOptions{}) + if err != nil && !kuberneteserrors.IsNotFound(err) { + return nil, errors.Wrap(err, "failed to get instance-tags secret") + } + + if kuberneteserrors.IsNotFound(err) { + return nil, ErrInstanceTagDataSecretNotFound + } + + if len(secret.Data) == 0 { + return nil, ErrInstanceTagDataIsEmpty + } + + tagDataBytes, ok := secret.Data[InstanceTagSecretKey] + if !ok || len(tagDataBytes) == 0 { + return nil, ErrInstanceTagDataIsEmpty + } + + tagData := &types.InstanceTagData{} + if err := tagData.UnmarshalBase64(tagDataBytes); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal instance tags") + } + + return tagData, nil +} diff --git a/pkg/tags/types/types.go b/pkg/tags/types/types.go new file mode 100644 index 00000000..825c51c7 --- /dev/null +++ b/pkg/tags/types/types.go @@ -0,0 +1,37 @@ +package types + +import ( + "encoding/base64" + "encoding/json" + + "github.com/pkg/errors" +) + +type InstanceTagData struct { + Force bool `json:"force"` + Tags map[string]string `json:"tags"` +} + +func (i InstanceTagData) IsEmpty() bool { + return len(i.Tags) == 0 +} + +func (i InstanceTagData) MarshalBase64() ([]byte, error) { + b, err := json.Marshal(i) + if err != nil { + return nil, err + } + return []byte(base64.StdEncoding.EncodeToString(b)), nil +} + +func (i *InstanceTagData) UnmarshalBase64(bs []byte) error { + b, err := base64.StdEncoding.DecodeString(string(bs)) + if err != nil { + return errors.Wrap(err, "failed to decode instance-tag data base64") + } + + if err := json.Unmarshal(b, &i); err != nil { + return errors.Wrap(err, "failed to unmarshal json") + } + return nil +} diff --git a/pkg/tags/types/types_test.go b/pkg/tags/types/types_test.go new file mode 100644 index 00000000..71a83d50 --- /dev/null +++ b/pkg/tags/types/types_test.go @@ -0,0 +1,75 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInstanceTagData(t *testing.T) { + tests := []struct { + name string + initFn func(tdata *InstanceTagData) + assertFn func(t *testing.T, tdata *InstanceTagData) + }{ + { + name: "IsEmpty returns true when there are no tags", + initFn: func(tdata *InstanceTagData) { + tdata.Tags = map[string]string{} + }, + assertFn: func(t *testing.T, tdata *InstanceTagData) { + assert.True(t, tdata.IsEmpty()) + }, + }, + { + name: "IsEmpty returns false when there is at least one tag", + initFn: func(tdata *InstanceTagData) { + tdata.Tags = map[string]string{ + "key": "value", + } + }, + assertFn: func(t *testing.T, tdata *InstanceTagData) { + assert.False(t, tdata.IsEmpty()) + }, + }, + { + name: "should marshal correctly to base64", + initFn: func(tdata *InstanceTagData) { + tdata.Force = true + tdata.Tags = map[string]string{ + "key": "value", + } + }, + assertFn: func(t *testing.T, tdata *InstanceTagData) { + b, err := tdata.MarshalBase64() + assert.NoError(t, err) + assert.Equal(t, "eyJmb3JjZSI6dHJ1ZSwidGFncyI6eyJrZXkiOiJ2YWx1ZSJ9fQ==", string(b)) + }, + }, + { + name: "should unmarshal struct correctly from base64", + initFn: func(tdata *InstanceTagData) {}, + assertFn: func(t *testing.T, tdata *InstanceTagData) { + err := tdata.UnmarshalBase64([]byte("eyJmb3JjZSI6dHJ1ZSwidGFncyI6eyJrZXkiOiJ2YWx1ZSJ9fQ==")) + assert.NoError(t, err) + + expected := &InstanceTagData{ + Force: true, + Tags: map[string]string{ + "key": "value", + }, + } + + assert.Equal(t, expected, tdata) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tdata := &InstanceTagData{} + tt.initFn(tdata) + tt.assertFn(t, tdata) + }) + } +}