From 2a305d53f807957b485d07153f7d540bc3e830c1 Mon Sep 17 00:00:00 2001 From: Dmitriy Ivolgin Date: Fri, 20 Dec 2024 15:24:28 -0800 Subject: [PATCH] /app/status API that returns detailed app status (#231) * /app/status API that returns detailed app status * Support new appStatus field in mock data --- pkg/apiserver/server.go | 1 + pkg/appstate/types/types.go | 18 +-- pkg/handlers/app.go | 97 +++++++++++++- pkg/handlers/mock.go | 16 ++- pkg/integration/data/default_mock_data.yaml | 9 +- ..._mock_data.yaml => test_mock_data_v1.yaml} | 0 pkg/integration/data/test_mock_data_v2.yaml | 41 ++++++ pkg/integration/mock.go | 71 ++++++++-- pkg/integration/mock_test.go | 126 +++++++++++++----- pkg/integration/types/types.go | 24 +++- 10 files changed, 340 insertions(+), 63 deletions(-) rename pkg/integration/data/{test_mock_data.yaml => test_mock_data_v1.yaml} (100%) create mode 100644 pkg/integration/data/test_mock_data_v2.yaml diff --git a/pkg/apiserver/server.go b/pkg/apiserver/server.go index 6b45342e..789875a6 100644 --- a/pkg/apiserver/server.go +++ b/pkg/apiserver/server.go @@ -68,6 +68,7 @@ func Start(params APIServerParams) { // app r.HandleFunc("/api/v1/app/info", handlers.GetCurrentAppInfo).Methods("GET") + r.HandleFunc("/api/v1/app/status", handlers.GetCurrentAppStatus).Methods("GET") r.HandleFunc("/api/v1/app/updates", handlers.GetAppUpdates).Methods("GET") r.HandleFunc("/api/v1/app/history", handlers.GetAppHistory).Methods("GET") cachedRouter.HandleFunc("/api/v1/app/custom-metrics", handlers.SendCustomAppMetrics).Methods("POST", "PATCH") diff --git a/pkg/appstate/types/types.go b/pkg/appstate/types/types.go index de5c5d15..fa1b438b 100644 --- a/pkg/appstate/types/types.go +++ b/pkg/appstate/types/types.go @@ -43,20 +43,20 @@ func (s StatusInformerString) Parse() (i StatusInformer, err error) { } type AppStatus struct { - AppSlug string `json:"appSlug"` - ResourceStates ResourceStates `json:"resourceStates" hash:"set"` - UpdatedAt time.Time `json:"updatedAt" hash:"ignore"` - State State `json:"state"` - Sequence int64 `json:"sequence"` + AppSlug string `json:"appSlug" yaml:"appSlug"` + ResourceStates ResourceStates `json:"resourceStates" yaml:"resourceStates" hash:"set"` + UpdatedAt time.Time `json:"updatedAt" yaml:"updatedAt" hash:"ignore"` + State State `json:"state" yaml:"state"` + Sequence int64 `json:"sequence" yaml:"sequence"` } type ResourceStates []ResourceState type ResourceState struct { - Kind string `json:"kind"` - Name string `json:"name"` - Namespace string `json:"namespace"` - State State `json:"state"` + Kind string `json:"kind" yaml:"kind"` + Name string `json:"name" yaml:"name"` + Namespace string `json:"namespace" yaml:"namespace"` + State State `json:"state" yaml:"state"` } type State string diff --git a/pkg/handlers/app.go b/pkg/handlers/app.go index d9999d24..0ea3d5d9 100644 --- a/pkg/handlers/app.go +++ b/pkg/handlers/app.go @@ -45,6 +45,10 @@ type GetCurrentAppInfoResponse struct { ReleaseSequence int64 `json:"releaseSequence"` } +type GetCurrentAppStatusResponse struct { + AppStatus appstatetypes.AppStatus `json:"appStatus"` +} + type GetAppHistoryResponse struct { Releases []AppRelease `json:"releases"` } @@ -102,11 +106,21 @@ func GetCurrentAppInfo(w http.ResponseWriter, r *http.Request) { return } - response.AppStatus = mockData.AppStatus - response.HelmChartURL = mockData.HelmChartURL - - if mockData.CurrentRelease != nil { - response.CurrentRelease = mockReleaseToAppRelease(*mockData.CurrentRelease) + switch mockData := mockData.(type) { + case *integrationtypes.MockDataV1: + response.AppStatus = mockData.AppStatus + response.HelmChartURL = mockData.HelmChartURL + if mockData.CurrentRelease != nil { + response.CurrentRelease = mockReleaseToAppRelease(*mockData.CurrentRelease) + } + case *integrationtypes.MockDataV2: + response.AppStatus = mockData.AppStatus.State + response.HelmChartURL = mockData.HelmChartURL + if mockData.CurrentRelease != nil { + response.CurrentRelease = mockReleaseToAppRelease(*mockData.CurrentRelease) + } + default: + logger.Errorf("unknown mock data type: %T", mockData) } JSON(w, http.StatusOK, response) @@ -149,6 +163,53 @@ func GetCurrentAppInfo(w http.ResponseWriter, r *http.Request) { JSON(w, http.StatusOK, response) } +func GetCurrentAppStatus(w http.ResponseWriter, r *http.Request) { + clientset, err := k8sutil.GetClientset() + if err != nil { + logger.Error(errors.Wrap(err, "failed to get clientset")) + w.WriteHeader(http.StatusInternalServerError) + return + } + + isIntegrationModeEnabled, err := integration.IsEnabled(r.Context(), clientset, store.GetStore().GetNamespace(), store.GetStore().GetLicense()) + if err != nil { + logger.Errorf("failed to check if integration mode is enabled: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + if isIntegrationModeEnabled { + response := GetCurrentAppStatusResponse{} + + mockData, err := integration.GetMockData(r.Context(), clientset, store.GetStore().GetNamespace()) + if err != nil { + logger.Errorf("failed to get mock data: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + switch mockData := mockData.(type) { + case *integrationtypes.MockDataV1: + logger.Errorf("app status is not supported in v1 mock data") + w.WriteHeader(http.StatusInternalServerError) + return + case *integrationtypes.MockDataV2: + response.AppStatus = mockData.AppStatus + default: + logger.Errorf("unknown mock data type: %T", mockData) + } + + JSON(w, http.StatusOK, response) + return + } + + response := GetCurrentAppStatusResponse{ + AppStatus: store.GetStore().GetAppStatus(), + } + + JSON(w, http.StatusOK, response) +} + func GetAppUpdates(w http.ResponseWriter, r *http.Request) { if util.IsAirgap() { JSON(w, http.StatusOK, []upstreamtypes.ChannelRelease{}) @@ -178,7 +239,18 @@ func GetAppUpdates(w http.ResponseWriter, r *http.Request) { } response := []upstreamtypes.ChannelRelease{} - for _, mockRelease := range mockData.AvailableReleases { + var avalableReleases []integrationtypes.MockRelease + + switch mockData := mockData.(type) { + case *integrationtypes.MockDataV1: + avalableReleases = mockData.AvailableReleases + case *integrationtypes.MockDataV2: + avalableReleases = mockData.AvailableReleases + default: + logger.Errorf("unknown mock data type: %T", mockData) + } + + for _, mockRelease := range avalableReleases { response = append(response, upstreamtypes.ChannelRelease{ VersionLabel: mockRelease.VersionLabel, CreatedAt: mockRelease.CreatedAt, @@ -244,10 +316,21 @@ func GetAppHistory(w http.ResponseWriter, r *http.Request) { return } + var deployedReleases []integrationtypes.MockRelease + + switch mockData := mockData.(type) { + case *integrationtypes.MockDataV1: + deployedReleases = mockData.DeployedReleases + case *integrationtypes.MockDataV2: + deployedReleases = mockData.DeployedReleases + default: + logger.Errorf("unknown mock data type: %T", mockData) + } + response := GetAppHistoryResponse{ Releases: []AppRelease{}, } - for _, mockRelease := range mockData.DeployedReleases { + for _, mockRelease := range deployedReleases { response.Releases = append(response.Releases, mockReleaseToAppRelease(mockRelease)) } diff --git a/pkg/handlers/mock.go b/pkg/handlers/mock.go index 6dcaef6e..55cff230 100644 --- a/pkg/handlers/mock.go +++ b/pkg/handlers/mock.go @@ -1,12 +1,11 @@ package handlers import ( - "encoding/json" + "io" "net/http" "github.com/pkg/errors" "github.com/replicatedhq/replicated-sdk/pkg/integration" - integrationtypes "github.com/replicatedhq/replicated-sdk/pkg/integration/types" "github.com/replicatedhq/replicated-sdk/pkg/k8sutil" "github.com/replicatedhq/replicated-sdk/pkg/logger" "github.com/replicatedhq/replicated-sdk/pkg/store" @@ -36,9 +35,16 @@ func PostIntegrationMockData(w http.ResponseWriter, r *http.Request) { return } - mockDataRequest := integrationtypes.MockData{} - if err := json.NewDecoder(r.Body).Decode(&mockDataRequest); err != nil { - logger.Error(err) + body, err := io.ReadAll(r.Body) + if err != nil { + logger.Errorf("failed to read request body: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + mockDataRequest, err := integration.UnmarshalJSON(body) + if err != nil { + logger.Errorf("failed to read request body: %v", err) w.WriteHeader(http.StatusBadRequest) return } diff --git a/pkg/integration/data/default_mock_data.yaml b/pkg/integration/data/default_mock_data.yaml index 65fb56b8..8959ebfe 100644 --- a/pkg/integration/data/default_mock_data.yaml +++ b/pkg/integration/data/default_mock_data.yaml @@ -1,4 +1,11 @@ -appStatus: ready +version: v2 +appStatus: + resourceStates: + - kind: deployment + name: dev-app + namespace: default + state: ready + state: ready helmChartURL: oci://registry.replicated.com/dev-app/dev-channel/dev-parent-chart currentRelease: versionLabel: 0.1.3 diff --git a/pkg/integration/data/test_mock_data.yaml b/pkg/integration/data/test_mock_data_v1.yaml similarity index 100% rename from pkg/integration/data/test_mock_data.yaml rename to pkg/integration/data/test_mock_data_v1.yaml diff --git a/pkg/integration/data/test_mock_data_v2.yaml b/pkg/integration/data/test_mock_data_v2.yaml new file mode 100644 index 00000000..44341462 --- /dev/null +++ b/pkg/integration/data/test_mock_data_v2.yaml @@ -0,0 +1,41 @@ +version: v2 +appStatus: + resourceStates: + - kind: deployment + name: dev-app + namespace: default + state: ready + state: ready +helmChartURL: oci://custom.registry.com/my-app +currentRelease: + versionLabel: custom-label + releaseNotes: "custom release notes" + createdAt: 2023-05-23T20:58:07Z + deployedAt: 2023-05-23T21:58:07Z + helmReleaseName: custom-helm-release-name + helmReleaseRevision: 4 + helmReleaseNamespace: default +deployedReleases: +- versionLabel: custom-version-label-1 + releaseNotes: "custom release notes 1" + createdAt: 2023-05-21T20:58:07Z + deployedAt: 2023-05-21T21:58:07Z + helmReleaseName: custom-helm-release-name-1 + helmReleaseRevision: 2 + helmReleaseNamespace: default +- versionLabel: custom-version-label-2 + releaseNotes: "custom release notes 2" + createdAt: 2023-05-22T20:58:07Z + deployedAt: 2023-05-22T21:58:07Z + helmReleaseName: custom-helm-release-name-2 + helmReleaseRevision: 3 + helmReleaseNamespace: default +availableReleases: +- versionLabel: custom-label-1 + releaseNotes: "custom release notes 1" + createdAt: 2023-05-24T20:58:07Z + deployedAt: 2023-05-24T21:58:07Z +- versionLabel: custom-label-2 + releaseNotes: "custom release notes 2" + createdAt: 2023-06-01T20:58:07Z + deployedAt: 2023-06-01T21:58:07Z \ No newline at end of file diff --git a/pkg/integration/mock.go b/pkg/integration/mock.go index 37e678cd..a96ae6ec 100644 --- a/pkg/integration/mock.go +++ b/pkg/integration/mock.go @@ -3,6 +3,7 @@ package integration import ( "context" _ "embed" + "encoding/json" "github.com/pkg/errors" "github.com/replicatedhq/replicated-sdk/pkg/integration/types" @@ -18,7 +19,7 @@ var ( defaultMockDataYAML []byte ) -func GetMockData(ctx context.Context, clientset kubernetes.Interface, namespace string) (*types.MockData, error) { +func GetMockData(ctx context.Context, clientset kubernetes.Interface, namespace string) (types.MockData, error) { replicatedSecretLock.Lock() defer replicatedSecretLock.Unlock() @@ -29,23 +30,23 @@ func GetMockData(ctx context.Context, clientset kubernetes.Interface, namespace if err == nil { b := secret.Data[integrationMockDataKey] if len(b) != 0 { - var mockData types.MockData - if err := yaml.Unmarshal(b, &mockData); err != nil { + mockData, err := UnmarshalYAML(b) + if err != nil { return nil, errors.Wrap(err, "failed to unmarshal mock data") } - return &mockData, nil + return mockData, nil } } return GetDefaultMockData(ctx) } -func GetDefaultMockData(ctx context.Context) (*types.MockData, error) { - var mockData types.MockData - if err := yaml.Unmarshal(defaultMockDataYAML, &mockData); err != nil { +func GetDefaultMockData(ctx context.Context) (types.MockData, error) { + mockData, err := UnmarshalYAML(defaultMockDataYAML) + if err != nil { return nil, errors.Wrap(err, "failed to unmarshal default mock data") } - return &mockData, nil + return mockData, nil } func SetMockData(ctx context.Context, clientset kubernetes.Interface, namespace string, mockData types.MockData) error { @@ -74,3 +75,57 @@ func SetMockData(ctx context.Context, clientset kubernetes.Interface, namespace return nil } + +func UnmarshalJSON(b []byte) (types.MockData, error) { + version := types.MockDataVersion{} + err := json.Unmarshal(b, &version) + if err != nil { + return nil, errors.Wrap(err, "failed to unmarshal mock data version") + } + + switch version.Version { + case "v1", "": + mockData := &types.MockDataV1{} + err = json.Unmarshal(b, &mockData) + if err != nil { + return nil, errors.Wrap(err, "failed to unmarshal mock data v1") + } + return mockData, nil + case "v2": + mockData := &types.MockDataV2{} + err = json.Unmarshal(b, &mockData) + if err != nil { + return nil, errors.Wrap(err, "failed to unmarshal mock data v2") + } + return mockData, nil + default: + return nil, errors.Errorf("unknown mock data version: %s", version.Version) + } +} + +func UnmarshalYAML(b []byte) (types.MockData, error) { + version := types.MockDataVersion{} + err := yaml.Unmarshal(b, &version) + if err != nil { + return nil, errors.Wrap(err, "failed to unmarshal mock data version") + } + + switch version.Version { + case "v1", "": + mockData := &types.MockDataV1{} + err = yaml.Unmarshal(b, &mockData) + if err != nil { + return nil, errors.Wrap(err, "failed to unmarshal mock data v1") + } + return mockData, nil + case "v2": + mockData := &types.MockDataV2{} + err = yaml.Unmarshal(b, &mockData) + if err != nil { + return nil, errors.Wrap(err, "failed to unmarshal mock data v2") + } + return mockData, nil + default: + return nil, errors.Errorf("unknown mock data version: %s", version.Version) + } +} diff --git a/pkg/integration/mock_test.go b/pkg/integration/mock_test.go index b67b5a4b..7f4acb9f 100644 --- a/pkg/integration/mock_test.go +++ b/pkg/integration/mock_test.go @@ -12,21 +12,26 @@ import ( integrationtypes "github.com/replicatedhq/replicated-sdk/pkg/integration/types" "github.com/replicatedhq/replicated-sdk/pkg/util" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v2" 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" ) -//go:embed data/test_mock_data.yaml -var testMockDataYAML []byte +//go:embed data/test_mock_data_v1.yaml +var testMockDataV1YAML []byte + +//go:embed data/test_mock_data_v2.yaml +var testMockDataV2YAML []byte func TestMock_GetMockData(t *testing.T) { defaultMockData, err := GetDefaultMockData(context.Background()) require.NoError(t, err) - testMockData, err := GetTestMockData() + testMockDataV1, err := GetTestMockData(testMockDataV1YAML) + require.NoError(t, err) + + testMockDataV2, err := GetTestMockData(testMockDataV2YAML) require.NoError(t, err) type fields struct { @@ -36,7 +41,7 @@ func TestMock_GetMockData(t *testing.T) { tests := []struct { name string fields fields - want *integrationtypes.MockData + want integrationtypes.MockData wantErr bool }{ { @@ -49,7 +54,7 @@ func TestMock_GetMockData(t *testing.T) { wantErr: false, }, { - name: "custom mock data current release", + name: "custom v1 mock data current release", fields: fields{ clientset: fake.NewSimpleClientset(&corev1.SecretList{ TypeMeta: metav1.TypeMeta{}, @@ -61,13 +66,35 @@ func TestMock_GetMockData(t *testing.T) { Namespace: "default", }, Data: map[string][]byte{ - integrationMockDataKey: []byte(testMockDataYAML), + integrationMockDataKey: []byte(testMockDataV1YAML), }, }}, }), namespace: "default", }, - want: testMockData, + want: testMockDataV1, + wantErr: false, + }, + { + name: "custom v2 mock data current release", + fields: fields{ + clientset: fake.NewSimpleClientset(&corev1.SecretList{ + TypeMeta: metav1.TypeMeta{}, + ListMeta: metav1.ListMeta{}, + Items: []corev1.Secret{{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: util.GetReplicatedSecretName(), + Namespace: "default", + }, + Data: map[string][]byte{ + integrationMockDataKey: []byte(testMockDataV2YAML), + }, + }}, + }), + namespace: "default", + }, + want: testMockDataV2, wantErr: false, }, } @@ -86,7 +113,10 @@ func TestMock_GetMockData(t *testing.T) { } func TestMock_SetMockData(t *testing.T) { - testMockData, err := GetTestMockData() + testMockDataV1, err := GetTestMockData(testMockDataV1YAML) + require.NoError(t, err) + + testMockDataV2, err := GetTestMockData(testMockDataV2YAML) require.NoError(t, err) type fields struct { @@ -94,17 +124,16 @@ func TestMock_SetMockData(t *testing.T) { namespace string } type args struct { - mockData *integrationtypes.MockData + mockData integrationtypes.MockData } tests := []struct { - name string - fields fields - args args - want *integrationtypes.MockData - wantErr bool + name string + fields fields + args args + validate func(t *testing.T, got integrationtypes.MockData) }{ { - name: "updates the replicated secret with the mock data", + name: "updates the replicated secret with the mock data v1", fields: fields{ clientset: fake.NewSimpleClientset(&corev1.SecretList{ TypeMeta: metav1.TypeMeta{}, @@ -121,37 +150,70 @@ func TestMock_SetMockData(t *testing.T) { namespace: "default", }, args: args{ - mockData: testMockData, + mockData: testMockDataV1, }, - want: testMockData, - wantErr: false, + validate: func(t *testing.T, got integrationtypes.MockData) { + gotV1, ok := got.(*integrationtypes.MockDataV1) + if !ok { + t.Errorf("SetMockData() expected type %T, got %T", &integrationtypes.MockDataV1{}, got) + } + require.True(t, ok) + if !reflect.DeepEqual(testMockDataV1, gotV1) { + t.Errorf("SetMockData() \n\n%q", fmtJSONDiff(gotV1, testMockDataV1)) + } + }, + }, + { + name: "updates the replicated secret with the mock data v2", + fields: fields{ + clientset: fake.NewSimpleClientset(&corev1.SecretList{ + TypeMeta: metav1.TypeMeta{}, + ListMeta: metav1.ListMeta{}, + Items: []corev1.Secret{{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: util.GetReplicatedSecretName(), + Namespace: "default", + }, + Data: map[string][]byte{}, + }}, + }), + namespace: "default", + }, + args: args{ + mockData: testMockDataV2, + }, + validate: func(t *testing.T, got integrationtypes.MockData) { + gotV2, ok := got.(*integrationtypes.MockDataV2) + if !ok { + t.Errorf("SetMockData() expected type %T, got %T", &integrationtypes.MockDataV2{}, got) + } + require.True(t, ok) + if !reflect.DeepEqual(testMockDataV2, gotV2) { + t.Errorf("SetMockData() \n\n%q", fmtJSONDiff(gotV2, testMockDataV2)) + } + }, + // want: testMockDataV2, + // wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := SetMockData(context.Background(), tt.fields.clientset, tt.fields.namespace, *tt.args.mockData) - if (err != nil) != tt.wantErr { - t.Errorf("SetMockData() error = %v, wantErr %v", err, tt.wantErr) - return - } + err := SetMockData(context.Background(), tt.fields.clientset, tt.fields.namespace, tt.args.mockData) secret, err := tt.fields.clientset.CoreV1().Secrets(tt.fields.namespace).Get(context.Background(), util.GetReplicatedSecretName(), metav1.GetOptions{}) require.NoError(t, err) - var got integrationtypes.MockData - err = yaml.Unmarshal(secret.Data[integrationMockDataKey], &got) + got, err := UnmarshalYAML(secret.Data[integrationMockDataKey]) require.NoError(t, err) - if !reflect.DeepEqual(tt.want, &got) { - t.Errorf("SetMockData() \n\n%s", fmtJSONDiff(got, tt.want)) - } + tt.validate(t, got) }) } } -func GetTestMockData() (*integrationtypes.MockData, error) { - var testMockData *integrationtypes.MockData - err := yaml.Unmarshal([]byte(testMockDataYAML), &testMockData) +func GetTestMockData(b []byte) (integrationtypes.MockData, error) { + testMockData, err := UnmarshalYAML(b) if err != nil { return nil, err } diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index 31da0c89..6b2e5386 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -4,7 +4,20 @@ import ( appstatetypes "github.com/replicatedhq/replicated-sdk/pkg/appstate/types" ) -type MockData struct { +type MockData interface { + GetVersion() string +} + +type MockDataVersion struct { + Version string `json:"version,omitempty" yaml:"version,omitempty"` +} + +func (m *MockDataVersion) GetVersion() string { + return m.Version +} + +type MockDataV1 struct { + MockDataVersion `json:",inline" yaml:",inline"` AppStatus appstatetypes.State `json:"appStatus,omitempty" yaml:"appStatus,omitempty"` HelmChartURL string `json:"helmChartURL,omitempty" yaml:"helmChartURL,omitempty"` CurrentRelease *MockRelease `json:"currentRelease,omitempty" yaml:"currentRelease,omitempty"` @@ -12,6 +25,15 @@ type MockData struct { AvailableReleases []MockRelease `json:"availableReleases,omitempty" yaml:"availableReleases,omitempty"` } +type MockDataV2 struct { + MockDataVersion `json:",inline" yaml:",inline"` + AppStatus appstatetypes.AppStatus `json:"appStatus,omitempty" yaml:"appStatus,omitempty"` + HelmChartURL string `json:"helmChartURL,omitempty" yaml:"helmChartURL,omitempty"` + CurrentRelease *MockRelease `json:"currentRelease,omitempty" yaml:"currentRelease,omitempty"` + DeployedReleases []MockRelease `json:"deployedReleases,omitempty" yaml:"deployedReleases,omitempty"` + AvailableReleases []MockRelease `json:"availableReleases,omitempty" yaml:"availableReleases,omitempty"` +} + type MockRelease struct { VersionLabel string `json:"versionLabel" yaml:"versionLabel"` ReleaseNotes string `json:"releaseNotes" yaml:"releaseNotes"`