From 0308028ab4642b689d44fd964024057ea5f035fb Mon Sep 17 00:00:00 2001 From: healthjyk Date: Fri, 10 May 2024 19:05:26 +0800 Subject: [PATCH] feat: realize release storage of local, oss and s3 --- pkg/engine/release/storage.go | 18 +- pkg/engine/release/storages/local.go | 126 ++++++ pkg/engine/release/storages/local_test.go | 405 ++++++++++++++++++ pkg/engine/release/storages/oss.go | 149 +++++++ pkg/engine/release/storages/oss_test.go | 184 ++++++++ pkg/engine/release/storages/s3.go | 170 ++++++++ pkg/engine/release/storages/s3_test.go | 186 ++++++++ .../test_project/test_ws/.metadata.yml | 1 + .../test_project/test_ws/.metadata.yml | 11 + .../releases/test_project/test_ws/1.yaml | 53 +++ .../releases/test_project/test_ws/2.yaml | 53 +++ .../releases/test_project/test_ws/3.yaml | 53 +++ pkg/engine/release/storages/util.go | 101 +++++ pkg/engine/release/storages/util_test.go | 179 ++++++++ 14 files changed, 1680 insertions(+), 9 deletions(-) create mode 100644 pkg/engine/release/storages/local.go create mode 100644 pkg/engine/release/storages/local_test.go create mode 100644 pkg/engine/release/storages/oss.go create mode 100644 pkg/engine/release/storages/oss_test.go create mode 100644 pkg/engine/release/storages/s3.go create mode 100644 pkg/engine/release/storages/s3_test.go create mode 100644 pkg/engine/release/storages/testdata/invalid_releases/test_project/test_ws/.metadata.yml create mode 100644 pkg/engine/release/storages/testdata/releases/test_project/test_ws/.metadata.yml create mode 100644 pkg/engine/release/storages/testdata/releases/test_project/test_ws/1.yaml create mode 100644 pkg/engine/release/storages/testdata/releases/test_project/test_ws/2.yaml create mode 100644 pkg/engine/release/storages/testdata/releases/test_project/test_ws/3.yaml create mode 100644 pkg/engine/release/storages/util.go create mode 100644 pkg/engine/release/storages/util_test.go diff --git a/pkg/engine/release/storage.go b/pkg/engine/release/storage.go index 4331190d..ede3ccf1 100644 --- a/pkg/engine/release/storage.go +++ b/pkg/engine/release/storage.go @@ -4,20 +4,20 @@ import ( v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" ) -// Storage is used to provide storage service for multiple releases. +// Storage is used to provide storage service for multiple Releases of a specified Project +// and Workspace. type Storage interface { - // Get returns a specified Release which is determined by the group of Project, Workspace - // and Revision. - Get(project, workspace string, revision uint64) (*v1.Release, error) + // Get returns a specified Release by Revision. + Get(revision uint64) (*v1.Release, error) - // GetRevisions returns the Revisions of a specified Project and Workspace. - GetRevisions(project, workspace string) ([]uint64, error) + // GetRevisions returns all the Revisions. + GetRevisions() []uint64 - // GetStackBoundRevisions returns the Revisions of a specified Project, Stack and Workspace. - GetStackBoundRevisions(project, stack, workspace string) ([]uint64, error) + // GetStackBoundRevisions returns the Revisions of a specified Stack. + GetStackBoundRevisions(stack string) []uint64 // GetLatestRevision returns the latest State which corresponds to the current infra Resources. - GetLatestRevision(project, workspace string) (uint64, error) + GetLatestRevision() uint64 // Create creates a new Release in the Storage. Create(release *v1.Release) error diff --git a/pkg/engine/release/storages/local.go b/pkg/engine/release/storages/local.go new file mode 100644 index 00000000..724e337e --- /dev/null +++ b/pkg/engine/release/storages/local.go @@ -0,0 +1,126 @@ +package storages + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" + + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" +) + +// LocalStorage is an implementation of release.Storage which uses local filesystem as storage. +type LocalStorage struct { + // The directory path to store the release files. + path string + + meta *releasesMetaData +} + +// NewLocalStorage news local release storage, and derives metadata. +func NewLocalStorage(path string) (*LocalStorage, error) { + s := &LocalStorage{path: path} + + // create the releases directory + if err := os.MkdirAll(s.path, os.ModePerm); err != nil { + return nil, fmt.Errorf("create releases directory failed, %w", err) + } + // read releases metadata + if err := s.readMeta(); err != nil { + return nil, err + } + + return s, nil +} + +func (s *LocalStorage) Get(revision uint64) (*v1.Release, error) { + if !checkRevisionExistence(s.meta, revision) { + return nil, ErrReleaseNotExist + } + + content, err := os.ReadFile(filepath.Join(s.path, fmt.Sprintf("%d%s", revision, yamlSuffix))) + if err != nil { + return nil, fmt.Errorf("read release file failed: %w", err) + } + + r := &v1.Release{} + if err = yaml.Unmarshal(content, r); err != nil { + return nil, fmt.Errorf("yaml unmarshal release failed: %w", err) + } + return r, nil +} + +func (s *LocalStorage) GetRevisions() []uint64 { + return getRevisions(s.meta) +} + +func (s *LocalStorage) GetStackBoundRevisions(stack string) []uint64 { + return getStackBoundRevisions(s.meta, stack) +} + +func (s *LocalStorage) GetLatestRevision() uint64 { + return s.meta.LatestRevision +} + +func (s *LocalStorage) Create(r *v1.Release) error { + if checkRevisionExistence(s.meta, r.Revision) { + return ErrReleaseAlreadyExist + } + + if err := s.writeRelease(r); err != nil { + return err + } + + addLatestReleaseMetaData(s.meta, r.Revision, r.Stack, r.Phase) + return s.writeMeta() +} + +func (s *LocalStorage) Update(r *v1.Release) error { + if !checkRevisionExistence(s.meta, r.Revision) { + return ErrReleaseNotExist + } + + return s.writeRelease(r) +} + +func (s *LocalStorage) readMeta() error { + content, err := os.ReadFile(filepath.Join(s.path, metadataFile)) + if os.IsNotExist(err) { + s.meta = &releasesMetaData{} + return nil + } else if err != nil { + return fmt.Errorf("read releases metadata file failed: %w", err) + } + + meta := &releasesMetaData{} + if err = yaml.Unmarshal(content, meta); err != nil { + return fmt.Errorf("yaml unmarshal releases metadata failed: %w", err) + } + s.meta = meta + return nil +} + +func (s *LocalStorage) writeMeta() error { + content, err := yaml.Marshal(s.meta) + if err != nil { + return fmt.Errorf("yaml marshal releases metadata failed: %w", err) + } + + if err = os.WriteFile(filepath.Join(s.path, metadataFile), content, os.ModePerm); err != nil { + return fmt.Errorf("write releases metadata file failed: %w", err) + } + return nil +} + +func (s *LocalStorage) writeRelease(r *v1.Release) error { + content, err := yaml.Marshal(r) + if err != nil { + return fmt.Errorf("yaml marshal release failed: %w", err) + } + + if err = os.WriteFile(filepath.Join(s.path, fmt.Sprintf("%d%s", r.Revision, yamlSuffix)), content, os.ModePerm); err != nil { + return fmt.Errorf("write release file failed: %w", err) + } + return nil +} diff --git a/pkg/engine/release/storages/local_test.go b/pkg/engine/release/storages/local_test.go new file mode 100644 index 00000000..ba269a7d --- /dev/null +++ b/pkg/engine/release/storages/local_test.go @@ -0,0 +1,405 @@ +package storages + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" + + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" +) + +func testDataFolder(releasePath string) string { + pwd, _ := os.Getwd() + return filepath.Join(pwd, "testdata", releasePath, "test_project", "test_ws") +} + +func mockRelease(revision uint64) *v1.Release { + loc, _ := time.LoadLocation("Asia/Shanghai") + return &v1.Release{ + Project: "test_project", + Workspace: "test_ws", + Revision: revision, + Stack: "test_stack", + Spec: &v1.Spec{ + Resources: v1.Resources{ + v1.Resource{ + ID: "apps.kusionstack.io/v1alpha1:PodTransitionRule:fakeNs:default-dev-foo", + Type: "Kubernetes", + Attributes: map[string]interface{}{ + "apiVersion": "apps.kusionstack.io/v1alpha1", + "kind": "PodTransitionRule", + "metadata": map[string]interface{}{ + "creationTimestamp": interface{}(nil), + "name": "default-dev-foo", + "namespace": "fakeNs", + }, + "spec": map[string]interface{}{ + "rules": []interface{}{map[string]interface{}{ + "availablePolicy": map[string]interface{}{ + "maxUnavailableValue": "30%", + }, + "name": "maxUnavailable", + }}, + "selector": map[string]interface{}{ + "matchLabels": map[string]interface{}{ + "app.kubernetes.io/name": "foo", "app.kubernetes.io/part-of": "default", + }, + }, + }, "status": map[string]interface{}{}, + }, + DependsOn: []string(nil), + Extensions: map[string]interface{}{ + "GVK": "apps.kusionstack.io/v1alpha1, Kind=PodTransitionRule", + }, + }, + }, + }, + State: &v1.State{ + Resources: v1.Resources{ + v1.Resource{ + ID: "apps.kusionstack.io/v1alpha1:PodTransitionRule:fakeNs:default-dev-foo", + Type: "Kubernetes", + Attributes: map[string]interface{}{ + "apiVersion": "apps.kusionstack.io/v1alpha1", + "kind": "PodTransitionRule", + "metadata": map[string]interface{}{ + "creationTimestamp": interface{}(nil), + "name": "default-dev-foo", + "namespace": "fakeNs", + }, + "spec": map[string]interface{}{ + "rules": []interface{}{map[string]interface{}{ + "availablePolicy": map[string]interface{}{ + "maxUnavailableValue": "30%", + }, + "name": "maxUnavailable", + }}, + "selector": map[string]interface{}{ + "matchLabels": map[string]interface{}{ + "app.kubernetes.io/name": "foo", "app.kubernetes.io/part-of": "default", + }, + }, + }, "status": map[string]interface{}{}, + }, + DependsOn: []string(nil), + Extensions: map[string]interface{}{ + "GVK": "apps.kusionstack.io/v1alpha1, Kind=PodTransitionRule", + }, + }, + }, + }, + Phase: v1.ReleasePhaseSucceeded, + CreateTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), + ModifiedTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), + } +} + +func mockReleaseRevision1Content() string { + return ` +project: test_project +workspace: test_ws +revision: 1 +stack: test_stack +spec: + resources: + - id: apps.kusionstack.io/v1alpha1:PodTransitionRule:fakeNs:default-dev-foo + type: Kubernetes + attributes: + apiVersion: apps.kusionstack.io/v1alpha1 + kind: PodTransitionRule + metadata: + creationTimestamp: null + name: default-dev-foo + namespace: fakeNs + spec: + rules: + - availablePolicy: + maxUnavailableValue: 30% + name: maxUnavailable + selector: + matchLabels: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + status: {} + extensions: + GVK: apps.kusionstack.io/v1alpha1, Kind=PodTransitionRule +state: + resources: + - id: apps.kusionstack.io/v1alpha1:PodTransitionRule:fakeNs:default-dev-foo + type: Kubernetes + attributes: + apiVersion: apps.kusionstack.io/v1alpha1 + kind: PodTransitionRule + metadata: + creationTimestamp: null + name: default-dev-foo + namespace: fakeNs + spec: + rules: + - availablePolicy: + maxUnavailableValue: 30% + name: maxUnavailable + selector: + matchLabels: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + status: {} + extensions: + GVK: apps.kusionstack.io/v1alpha1, Kind=PodTransitionRule +phase: succeeded +createTime: 2024-05-10T16:48:00+08:00 +modifiedTime: 2024-05-10T16:48:00+08:00 +` +} + +func mockReleaseMeta(revision uint64) *releaseMetaData { + return &releaseMetaData{ + Revision: revision, + Stack: "test_stack", + Phase: v1.ReleasePhaseSucceeded, + } +} + +func mockReleasesMeta() *releasesMetaData { + return &releasesMetaData{ + LatestRevision: 3, + ReleaseMetaDatas: []*releaseMetaData{ + mockReleaseMeta(1), + mockReleaseMeta(2), + mockReleaseMeta(3), + }, + } +} + +func TestNewLocalStorage(t *testing.T) { + testcases := []struct { + name string + success bool + path string + expectedMeta *releasesMetaData + deletePath bool + }{ + { + name: "new local storage with empty directory", + success: true, + path: "empty_releases", + expectedMeta: &releasesMetaData{}, + deletePath: true, + }, + { + name: "new local storage with exist directory", + success: true, + path: "releases", + expectedMeta: mockReleasesMeta(), + deletePath: false, + }, + { + name: "new local storage failed", + success: false, + path: "invalid_releases", + expectedMeta: nil, + deletePath: false, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + s, err := NewLocalStorage(testDataFolder(tc.path)) + assert.Equal(t, tc.success, err == nil) + if tc.success { + expectedMetaContent, _ := yaml.Marshal(tc.expectedMeta) + metaContent, _ := yaml.Marshal(s.meta) + assert.Equal(t, string(expectedMetaContent), string(metaContent)) + } + if tc.deletePath { + pwd, _ := os.Getwd() + _ = os.RemoveAll(filepath.Join(pwd, "testdata", tc.path)) + } + }) + } +} + +func TestLocalStorage_Get(t *testing.T) { + testcases := []struct { + name string + success bool + revision uint64 + expectedRelease *v1.Release + }{ + { + name: "get release successfully", + success: true, + revision: 1, + expectedRelease: mockRelease(1), + }, + { + name: "get release failed not exist", + success: false, + revision: 4, + expectedRelease: nil, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + s, err := NewLocalStorage(testDataFolder("releases")) + assert.NoError(t, err) + r, err := s.Get(tc.revision) + assert.Equal(t, tc.success, err == nil) + if tc.success { + expectedReleaseContent, _ := yaml.Marshal(tc.expectedRelease) + releaseContent, _ := yaml.Marshal(r) + assert.Equal(t, string(expectedReleaseContent), string(releaseContent)) + } + }) + } +} + +func TestLocalStorage_GetRevisions(t *testing.T) { + testcases := []struct { + name string + expectedRevisions []uint64 + }{ + { + name: "get release revisions successfully", + expectedRevisions: []uint64{1, 2, 3}, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + s, err := NewLocalStorage(testDataFolder("releases")) + assert.NoError(t, err) + revisions := s.GetRevisions() + assert.Equal(t, tc.expectedRevisions, revisions) + }) + } +} + +func TestLocalStorage_GetStackBoundRevisions(t *testing.T) { + testcases := []struct { + name string + stack string + expectedRevisions []uint64 + }{ + { + name: "get stack bound release revisions successfully", + stack: "test_stack", + expectedRevisions: []uint64{1, 2, 3}, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + s, err := NewLocalStorage(testDataFolder("releases")) + assert.NoError(t, err) + revisions := s.GetStackBoundRevisions(tc.stack) + assert.Equal(t, tc.expectedRevisions, revisions) + }) + } +} + +func TestLocalStorage_GetLatestRevision(t *testing.T) { + testcases := []struct { + name string + expectedRevision uint64 + }{ + { + name: "get latest release revision successfully", + expectedRevision: 3, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + s, err := NewLocalStorage(testDataFolder("releases")) + assert.NoError(t, err) + revision := s.GetLatestRevision() + assert.Equal(t, tc.expectedRevision, revision) + }) + } +} + +func TestLocalStorage_Create(t *testing.T) { + testcases := []struct { + name string + success bool + releasePath string + revision uint64 + expectedMeta *releasesMetaData + deletePath bool + }{ + { + name: "create release successfully", + success: true, + releasePath: "empty_releases", + revision: 1, + expectedMeta: &releasesMetaData{ + LatestRevision: 1, + ReleaseMetaDatas: []*releaseMetaData{ + mockReleaseMeta(1), + }, + }, + deletePath: true, + }, + { + name: "create release failed already exist", + success: false, + releasePath: "releases", + revision: 3, + expectedMeta: nil, + deletePath: false, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + s, err := NewLocalStorage(testDataFolder(tc.releasePath)) + assert.NoError(t, err) + err = s.Create(mockRelease(tc.revision)) + assert.Equal(t, tc.success, err == nil) + if tc.success { + releaseFile := filepath.Join(testDataFolder(tc.releasePath), fmt.Sprintf("%d%s", tc.revision, yamlSuffix)) + _, err = os.Stat(releaseFile) + assert.NoError(t, err) + } + if tc.deletePath { + pwd, _ := os.Getwd() + _ = os.RemoveAll(filepath.Join(pwd, "testdata", tc.releasePath)) + } + }) + } +} + +func TestLocalStorage_Update(t *testing.T) { + testcases := []struct { + name string + success bool + revision uint64 + }{ + { + name: "update release successfully", + success: true, + revision: 3, + }, + { + name: "update release failed not exist", + success: false, + revision: 4, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + s, err := NewLocalStorage(testDataFolder("releases")) + assert.NoError(t, err) + err = s.Update(mockRelease(tc.revision)) + assert.Equal(t, tc.success, err == nil) + }) + } +} diff --git a/pkg/engine/release/storages/oss.go b/pkg/engine/release/storages/oss.go new file mode 100644 index 00000000..1081fe48 --- /dev/null +++ b/pkg/engine/release/storages/oss.go @@ -0,0 +1,149 @@ +package storages + +import ( + "bytes" + "fmt" + "io" + + "github.com/aliyun/aliyun-oss-go-sdk/oss" + "gopkg.in/yaml.v3" + + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" +) + +// OssStorage is an implementation of release.Storage which uses oss as storage. +type OssStorage struct { + bucket *oss.Bucket + + // The prefix to store the release files. + prefix string + + meta *releasesMetaData +} + +// NewOssStorage news oss release storage, and derives metadata. +func NewOssStorage(bucket *oss.Bucket, prefix string) (*OssStorage, error) { + s := &OssStorage{ + bucket: bucket, + prefix: prefix, + } + if err := s.readMeta(); err != nil { + return nil, err + } + + return s, nil +} + +func (s *OssStorage) Get(revision uint64) (*v1.Release, error) { + if !checkRevisionExistence(s.meta, revision) { + return nil, ErrReleaseNotExist + } + + body, err := s.bucket.GetObject(fmt.Sprintf("%s/%d%s", s.prefix, revision, yamlSuffix)) + if err != nil { + return nil, fmt.Errorf("get release from oss failed: %w", err) + } + defer func() { + _ = body.Close() + }() + content, err := io.ReadAll(body) + if err != nil { + return nil, fmt.Errorf("read release failed: %w", err) + } + + r := &v1.Release{} + if err = yaml.Unmarshal(content, r); err != nil { + return nil, fmt.Errorf("yaml unmarshal release failed: %w", err) + } + return r, nil +} + +func (s *OssStorage) GetRevisions() []uint64 { + return getRevisions(s.meta) +} + +func (s *OssStorage) GetStackBoundRevisions(stack string) []uint64 { + return getStackBoundRevisions(s.meta, stack) +} + +func (s *OssStorage) GetLatestRevision() uint64 { + return s.meta.LatestRevision +} + +func (s *OssStorage) Create(r *v1.Release) error { + if checkRevisionExistence(s.meta, r.Revision) { + return ErrReleaseAlreadyExist + } + + if err := s.writeRelease(r); err != nil { + return err + } + + addLatestReleaseMetaData(s.meta, r.Revision, r.Stack, r.Phase) + return s.writeMeta() +} + +func (s *OssStorage) Update(r *v1.Release) error { + if !checkRevisionExistence(s.meta, r.Revision) { + return ErrReleaseNotExist + } + + return s.writeRelease(r) +} + +func (s *OssStorage) readMeta() error { + body, err := s.bucket.GetObject(s.prefix + "/" + metadataFile) + if err != nil { + ossErr, ok := err.(oss.ServiceError) + // error code ref: github.com/aliyun/aliyun-oss-go-sdk@v2.1.8+incompatible/oss/bucket.go:553 + if ok && ossErr.StatusCode == 404 { + s.meta = &releasesMetaData{} + return nil + } + return fmt.Errorf("get releases metadata from oss failed: %w", err) + } + defer func() { + _ = body.Close() + }() + + content, err := io.ReadAll(body) + if err != nil { + return fmt.Errorf("read releases metadata failed: %w", err) + } + if len(content) == 0 { + s.meta = &releasesMetaData{} + return nil + } + + meta := &releasesMetaData{} + if err = yaml.Unmarshal(content, meta); err != nil { + return fmt.Errorf("yaml unmarshal releases metadata failed: %w", err) + } + s.meta = meta + return nil +} + +func (s *OssStorage) writeMeta() error { + content, err := yaml.Marshal(s.meta) + if err != nil { + return fmt.Errorf("yaml marshal releases metadata failed: %w", err) + } + + if err = s.bucket.PutObject(s.prefix+"/"+metadataFile, bytes.NewReader(content)); err != nil { + return fmt.Errorf("put releases metadata to oss failed: %w", err) + } + return nil +} + +func (s *OssStorage) writeRelease(r *v1.Release) error { + content, err := yaml.Marshal(r) + if err != nil { + return fmt.Errorf("yaml marshal release failed: %w", err) + } + + key := fmt.Sprintf("%s/%d%s", s.prefix, r.Revision, yamlSuffix) + if err = s.bucket.PutObject(key, bytes.NewReader(content)); err != nil { + return fmt.Errorf("put release to oss failed: %w", err) + } + return nil +} diff --git a/pkg/engine/release/storages/oss_test.go b/pkg/engine/release/storages/oss_test.go new file mode 100644 index 00000000..729704af --- /dev/null +++ b/pkg/engine/release/storages/oss_test.go @@ -0,0 +1,184 @@ +package storages + +import ( + "bytes" + "io" + "testing" + + "github.com/aliyun/aliyun-oss-go-sdk/oss" + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" + + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" +) + +func mockOssStorage() *OssStorage { + return &OssStorage{bucket: &oss.Bucket{}, meta: mockReleasesMeta()} +} + +func mockOssStorageWriteMeta() { + mockey.Mock((*OssStorage).writeMeta).Return(nil).Build() +} + +func mockOssStorageWriteRelease() { + mockey.Mock((*OssStorage).writeRelease).Return(nil).Build() +} + +func TestOssStorage_Get(t *testing.T) { + testcases := []struct { + name string + success bool + revision uint64 + content []byte + expectedRelease *v1.Release + }{ + { + name: "get release successfully", + success: true, + revision: 1, + content: []byte(mockReleaseRevision1Content()), + expectedRelease: mockRelease(1), + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + mockey.PatchConvey("mock oss operation", t, func() { + mockey.Mock(oss.Bucket.GetObject).Return(io.NopCloser(bytes.NewReader([]byte(""))), nil).Build() + mockey.Mock(io.ReadAll).Return(tc.content, nil).Build() + r, err := mockOssStorage().Get(tc.revision) + assert.Equal(t, tc.success, err == nil) + if tc.success { + expectedReleaseContent, _ := yaml.Marshal(tc.expectedRelease) + releaseContent, _ := yaml.Marshal(r) + assert.Equal(t, string(expectedReleaseContent), string(releaseContent)) + } + }) + }) + } +} + +func TestOssStorage_GetRevisions(t *testing.T) { + testcases := []struct { + name string + expectedRevisions []uint64 + }{ + { + name: "get release revisions successfully", + expectedRevisions: []uint64{1, 2, 3}, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + mockey.PatchConvey("mock oss operation", t, func() { + revisions := mockOssStorage().GetRevisions() + assert.Equal(t, tc.expectedRevisions, revisions) + }) + }) + } +} + +func TestOssStorage_GetStackBoundRevisions(t *testing.T) { + testcases := []struct { + name string + stack string + expectedRevisions []uint64 + }{ + { + name: "get stack bound release revisions successfully", + stack: "test_stack", + expectedRevisions: []uint64{1, 2, 3}, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + mockey.PatchConvey("mock oss operation", t, func() { + revisions := mockOssStorage().GetStackBoundRevisions(tc.stack) + assert.Equal(t, tc.expectedRevisions, revisions) + }) + }) + } +} + +func TestOssStorage_GetLatestRevision(t *testing.T) { + testcases := []struct { + name string + expectedRevision uint64 + }{ + { + name: "get latest release revision successfully", + expectedRevision: 3, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + mockey.PatchConvey("mock oss operation", t, func() { + revision := mockOssStorage().GetLatestRevision() + assert.Equal(t, tc.expectedRevision, revision) + }) + }) + } +} + +func TestOssStorage_Create(t *testing.T) { + testcases := []struct { + name string + success bool + r *v1.Release + }{ + { + name: "create release successfully", + success: true, + r: mockRelease(4), + }, + { + name: "failed to create release already exist", + success: false, + r: mockRelease(3), + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + mockey.PatchConvey("mock oss operation", t, func() { + mockOssStorageWriteMeta() + mockOssStorageWriteRelease() + err := mockOssStorage().Create(tc.r) + assert.Equal(t, tc.success, err == nil) + }) + }) + } +} + +func TestOssStorage_Update(t *testing.T) { + testcases := []struct { + name string + success bool + r *v1.Release + }{ + { + name: "update release successfully", + success: true, + r: mockRelease(3), + }, + { + name: "failed to update release not exist", + success: false, + r: mockRelease(4), + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + mockey.PatchConvey("mock oss operation", t, func() { + mockOssStorageWriteRelease() + err := mockOssStorage().Update(tc.r) + assert.Equal(t, tc.success, err == nil) + }) + }) + } +} diff --git a/pkg/engine/release/storages/s3.go b/pkg/engine/release/storages/s3.go new file mode 100644 index 00000000..5d03e976 --- /dev/null +++ b/pkg/engine/release/storages/s3.go @@ -0,0 +1,170 @@ +package storages + +import ( + "bytes" + "fmt" + "io" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/s3" + "gopkg.in/yaml.v3" + + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" +) + +// S3Storage is an implementation of release.Storage which uses s3 as storage. +type S3Storage struct { + s3 *s3.S3 + bucket string + + // The prefix to store the release files. + prefix string + + meta *releasesMetaData +} + +// NewS3Storage news s3 release storage, and derives metadata. +func NewS3Storage(s3 *s3.S3, bucket, prefix string) (*S3Storage, error) { + s := &S3Storage{ + s3: s3, + bucket: bucket, + prefix: prefix, + } + if err := s.readMeta(); err != nil { + return nil, err + } + return s, nil +} + +func (s *S3Storage) Get(revision uint64) (*v1.Release, error) { + if !checkRevisionExistence(s.meta, revision) { + return nil, ErrReleaseNotExist + } + + key := fmt.Sprintf("%s/%d%s", s.prefix, revision, yamlSuffix) + input := &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: &key, + } + output, err := s.s3.GetObject(input) + if err != nil { + return nil, fmt.Errorf("get release from s3 failed: %w", err) + } + defer func() { + _ = output.Body.Close() + }() + content, err := io.ReadAll(output.Body) + if err != nil { + return nil, fmt.Errorf("read release failed: %w", err) + } + + r := &v1.Release{} + if err = yaml.Unmarshal(content, r); err != nil { + return nil, fmt.Errorf("yaml unmarshal release failed: %w", err) + } + return r, nil +} + +func (s *S3Storage) GetRevisions() []uint64 { + return getRevisions(s.meta) +} + +func (s *S3Storage) GetStackBoundRevisions(stack string) []uint64 { + return getStackBoundRevisions(s.meta, stack) +} + +func (s *S3Storage) GetLatestRevision() uint64 { + return s.meta.LatestRevision +} + +func (s *S3Storage) Create(r *v1.Release) error { + if checkRevisionExistence(s.meta, r.Revision) { + return ErrReleaseAlreadyExist + } + + if err := s.writeRelease(r); err != nil { + return err + } + + addLatestReleaseMetaData(s.meta, r.Revision, r.Stack, r.Phase) + return s.writeMeta() +} + +func (s *S3Storage) Update(r *v1.Release) error { + if !checkRevisionExistence(s.meta, r.Revision) { + return ErrReleaseNotExist + } + + return s.writeRelease(r) +} + +func (s *S3Storage) readMeta() error { + key := s.prefix + "/" + metadataFile + input := &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: &key, + } + output, err := s.s3.GetObject(input) + if err != nil { + awsErr, ok := err.(awserr.Error) + if ok && awsErr.Code() == s3.ErrCodeNoSuchKey { + s.meta = &releasesMetaData{} + return nil + } + return fmt.Errorf("get releases metadata from s3 failed: %w", err) + } + defer func() { + _ = output.Body.Close() + }() + + content, err := io.ReadAll(output.Body) + if err != nil { + return fmt.Errorf("read releases metadata failed: %w", err) + } + if len(content) == 0 { + s.meta = &releasesMetaData{} + return nil + } + + meta := &releasesMetaData{} + if err = yaml.Unmarshal(content, meta); err != nil { + return fmt.Errorf("yaml unmarshal releases metadata failed: %w", err) + } + s.meta = meta + return nil +} + +func (s *S3Storage) writeMeta() error { + content, err := yaml.Marshal(s.meta) + if err != nil { + return fmt.Errorf("yaml marshal releases metadata failed: %w", err) + } + + input := &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(s.prefix + "/" + metadataFile), + Body: bytes.NewReader(content), + } + if _, err = s.s3.PutObject(input); err != nil { + return fmt.Errorf("put releases metadata to s3 failed: %w", err) + } + return nil +} + +func (s *S3Storage) writeRelease(r *v1.Release) error { + content, err := yaml.Marshal(r) + if err != nil { + return fmt.Errorf("yaml marshal release failed: %w", err) + } + + input := &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(fmt.Sprintf("%s/%d%s", s.prefix, r.Revision, yamlSuffix)), + Body: bytes.NewReader(content), + } + if _, err = s.s3.PutObject(input); err != nil { + return fmt.Errorf("put release to s3 failed: %w", err) + } + return nil +} diff --git a/pkg/engine/release/storages/s3_test.go b/pkg/engine/release/storages/s3_test.go new file mode 100644 index 00000000..08009df3 --- /dev/null +++ b/pkg/engine/release/storages/s3_test.go @@ -0,0 +1,186 @@ +package storages + +import ( + "bytes" + "io" + "testing" + + "github.com/aws/aws-sdk-go/service/s3" + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" + + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" +) + +func mockS3Storage() *S3Storage { + return &S3Storage{s3: &s3.S3{}, meta: mockReleasesMeta()} +} + +func mockS3StorageWriteMeta() { + mockey.Mock((*S3Storage).writeMeta).Return(nil).Build() +} + +func mockS3StorageWriteRelease() { + mockey.Mock((*S3Storage).writeRelease).Return(nil).Build() +} + +func TestS3Storage_Get(t *testing.T) { + testcases := []struct { + name string + success bool + revision uint64 + content []byte + expectedRelease *v1.Release + }{ + { + name: "get release successfully", + success: true, + revision: 1, + content: []byte(mockReleaseRevision1Content()), + expectedRelease: mockRelease(1), + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + mockey.PatchConvey("mock s3 operation", t, func() { + mockey.Mock((*s3.S3).GetObject).Return(&s3.GetObjectOutput{ + Body: io.NopCloser(bytes.NewReader([]byte(""))), + }, nil).Build() + mockey.Mock(io.ReadAll).Return(tc.content, nil).Build() + r, err := mockS3Storage().Get(tc.revision) + assert.Equal(t, tc.success, err == nil) + if tc.success { + expectedReleaseContent, _ := yaml.Marshal(tc.expectedRelease) + releaseContent, _ := yaml.Marshal(r) + assert.Equal(t, string(expectedReleaseContent), string(releaseContent)) + } + }) + }) + } +} + +func TestS3Storage_GetRevisions(t *testing.T) { + testcases := []struct { + name string + expectedRevisions []uint64 + }{ + { + name: "get release revisions successfully", + expectedRevisions: []uint64{1, 2, 3}, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + mockey.PatchConvey("mock s3 operation", t, func() { + revisions := mockS3Storage().GetRevisions() + assert.Equal(t, tc.expectedRevisions, revisions) + }) + }) + } +} + +func TestS3Storage_GetStackBoundRevisions(t *testing.T) { + testcases := []struct { + name string + stack string + expectedRevisions []uint64 + }{ + { + name: "get stack bound release revisions successfully", + stack: "test_stack", + expectedRevisions: []uint64{1, 2, 3}, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + mockey.PatchConvey("mock s3 operation", t, func() { + revisions := mockS3Storage().GetStackBoundRevisions(tc.stack) + assert.Equal(t, tc.expectedRevisions, revisions) + }) + }) + } +} + +func TestS3Storage_GetLatestRevision(t *testing.T) { + testcases := []struct { + name string + expectedRevision uint64 + }{ + { + name: "get latest release revision successfully", + expectedRevision: 3, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + mockey.PatchConvey("mock s3 operation", t, func() { + revision := mockS3Storage().GetLatestRevision() + assert.Equal(t, tc.expectedRevision, revision) + }) + }) + } +} + +func TestS3Storage_Create(t *testing.T) { + testcases := []struct { + name string + success bool + r *v1.Release + }{ + { + name: "create release successfully", + success: true, + r: mockRelease(4), + }, + { + name: "failed to create release already exist", + success: false, + r: mockRelease(3), + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + mockey.PatchConvey("mock s3 operation", t, func() { + mockS3StorageWriteMeta() + mockS3StorageWriteRelease() + err := mockS3Storage().Create(tc.r) + assert.Equal(t, tc.success, err == nil) + }) + }) + } +} + +func TestS3Storage_Update(t *testing.T) { + testcases := []struct { + name string + success bool + r *v1.Release + }{ + { + name: "update release successfully", + success: true, + r: mockRelease(3), + }, + { + name: "failed to update release not exist", + success: false, + r: mockRelease(4), + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + mockey.PatchConvey("mock s3 operation", t, func() { + mockS3StorageWriteRelease() + err := mockS3Storage().Update(tc.r) + assert.Equal(t, tc.success, err == nil) + }) + }) + } +} diff --git a/pkg/engine/release/storages/testdata/invalid_releases/test_project/test_ws/.metadata.yml b/pkg/engine/release/storages/testdata/invalid_releases/test_project/test_ws/.metadata.yml new file mode 100644 index 00000000..21d36afc --- /dev/null +++ b/pkg/engine/release/storages/testdata/invalid_releases/test_project/test_ws/.metadata.yml @@ -0,0 +1 @@ +invalid metadata \ No newline at end of file diff --git a/pkg/engine/release/storages/testdata/releases/test_project/test_ws/.metadata.yml b/pkg/engine/release/storages/testdata/releases/test_project/test_ws/.metadata.yml new file mode 100644 index 00000000..d381c3c0 --- /dev/null +++ b/pkg/engine/release/storages/testdata/releases/test_project/test_ws/.metadata.yml @@ -0,0 +1,11 @@ +latestRevision: 3 +releaseMetaDatas: + - revision: 1 + stack: test_stack + phase: succeeded + - revision: 2 + stack: test_stack + phase: succeeded + - revision: 3 + stack: test_stack + phase: succeeded diff --git a/pkg/engine/release/storages/testdata/releases/test_project/test_ws/1.yaml b/pkg/engine/release/storages/testdata/releases/test_project/test_ws/1.yaml new file mode 100644 index 00000000..501ab0ce --- /dev/null +++ b/pkg/engine/release/storages/testdata/releases/test_project/test_ws/1.yaml @@ -0,0 +1,53 @@ +project: test_project +workspace: test_ws +revision: 1 +stack: test_stack +spec: + resources: + - id: apps.kusionstack.io/v1alpha1:PodTransitionRule:fakeNs:default-dev-foo + type: Kubernetes + attributes: + apiVersion: apps.kusionstack.io/v1alpha1 + kind: PodTransitionRule + metadata: + creationTimestamp: null + name: default-dev-foo + namespace: fakeNs + spec: + rules: + - availablePolicy: + maxUnavailableValue: 30% + name: maxUnavailable + selector: + matchLabels: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + status: {} + extensions: + GVK: apps.kusionstack.io/v1alpha1, Kind=PodTransitionRule +state: + resources: + - id: apps.kusionstack.io/v1alpha1:PodTransitionRule:fakeNs:default-dev-foo + type: Kubernetes + attributes: + apiVersion: apps.kusionstack.io/v1alpha1 + kind: PodTransitionRule + metadata: + creationTimestamp: null + name: default-dev-foo + namespace: fakeNs + spec: + rules: + - availablePolicy: + maxUnavailableValue: 30% + name: maxUnavailable + selector: + matchLabels: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + status: {} + extensions: + GVK: apps.kusionstack.io/v1alpha1, Kind=PodTransitionRule +phase: succeeded +createTime: 2024-05-10T16:48:00+08:00 +modifiedTime: 2024-05-10T16:48:00+08:00 diff --git a/pkg/engine/release/storages/testdata/releases/test_project/test_ws/2.yaml b/pkg/engine/release/storages/testdata/releases/test_project/test_ws/2.yaml new file mode 100644 index 00000000..68541512 --- /dev/null +++ b/pkg/engine/release/storages/testdata/releases/test_project/test_ws/2.yaml @@ -0,0 +1,53 @@ +project: test_project +workspace: test_ws +revision: 2 +stack: test_stack +spec: + resources: + - id: apps.kusionstack.io/v1alpha1:PodTransitionRule:fakeNs:default-dev-foo + type: Kubernetes + attributes: + apiVersion: apps.kusionstack.io/v1alpha1 + kind: PodTransitionRule + metadata: + creationTimestamp: null + name: default-dev-foo + namespace: fakeNs + spec: + rules: + - availablePolicy: + maxUnavailableValue: 30% + name: maxUnavailable + selector: + matchLabels: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + status: {} + extensions: + GVK: apps.kusionstack.io/v1alpha1, Kind=PodTransitionRule +state: + resources: + - id: apps.kusionstack.io/v1alpha1:PodTransitionRule:fakeNs:default-dev-foo + type: Kubernetes + attributes: + apiVersion: apps.kusionstack.io/v1alpha1 + kind: PodTransitionRule + metadata: + creationTimestamp: null + name: default-dev-foo + namespace: fakeNs + spec: + rules: + - availablePolicy: + maxUnavailableValue: 30% + name: maxUnavailable + selector: + matchLabels: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + status: {} + extensions: + GVK: apps.kusionstack.io/v1alpha1, Kind=PodTransitionRule +phase: succeeded +createTime: 2024-05-10T16:48:00+08:00 +modifiedTime: 2024-05-10T16:48:00+08:00 diff --git a/pkg/engine/release/storages/testdata/releases/test_project/test_ws/3.yaml b/pkg/engine/release/storages/testdata/releases/test_project/test_ws/3.yaml new file mode 100644 index 00000000..f56485ea --- /dev/null +++ b/pkg/engine/release/storages/testdata/releases/test_project/test_ws/3.yaml @@ -0,0 +1,53 @@ +project: test_project +workspace: test_ws +revision: 3 +stack: test_stack +spec: + resources: + - id: apps.kusionstack.io/v1alpha1:PodTransitionRule:fakeNs:default-dev-foo + type: Kubernetes + attributes: + apiVersion: apps.kusionstack.io/v1alpha1 + kind: PodTransitionRule + metadata: + creationTimestamp: null + name: default-dev-foo + namespace: fakeNs + spec: + rules: + - availablePolicy: + maxUnavailableValue: 30% + name: maxUnavailable + selector: + matchLabels: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + status: {} + extensions: + GVK: apps.kusionstack.io/v1alpha1, Kind=PodTransitionRule +state: + resources: + - id: apps.kusionstack.io/v1alpha1:PodTransitionRule:fakeNs:default-dev-foo + type: Kubernetes + attributes: + apiVersion: apps.kusionstack.io/v1alpha1 + kind: PodTransitionRule + metadata: + creationTimestamp: null + name: default-dev-foo + namespace: fakeNs + spec: + rules: + - availablePolicy: + maxUnavailableValue: 30% + name: maxUnavailable + selector: + matchLabels: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + status: {} + extensions: + GVK: apps.kusionstack.io/v1alpha1, Kind=PodTransitionRule +phase: succeeded +createTime: 2024-05-10T16:48:00+08:00 +modifiedTime: 2024-05-10T16:48:00+08:00 diff --git a/pkg/engine/release/storages/util.go b/pkg/engine/release/storages/util.go new file mode 100644 index 00000000..b522345b --- /dev/null +++ b/pkg/engine/release/storages/util.go @@ -0,0 +1,101 @@ +package storages + +import ( + "errors" + "fmt" + "path/filepath" + "strings" + + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" +) + +const ( + releasesPrefix = "releases" + metadataFile = ".metadata.yml" + yamlSuffix = ".yaml" +) + +var ( + ErrReleaseNotExist = errors.New("release does not exist") + ErrReleaseAlreadyExist = errors.New("release has already existed") +) + +// GenReleaseDirPath generates the release dir path, which is used for LocalStorage. +func GenReleaseDirPath(dir, project, workspace string) string { + return filepath.Join(dir, releasesPrefix, project, workspace) +} + +// GenGenericOssReleasePrefixKey generates generic oss release prefix, which is use for OssStorage and S3Storage. +func GenGenericOssReleasePrefixKey(prefix, project, workspace string) string { + prefix = strings.TrimPrefix(prefix, "/") + if prefix != "" { + prefix += "/" + } + return fmt.Sprintf("%s%s/%s/%s", prefix, releasesPrefix, project, workspace) +} + +// releasesMetaData contains mata data of the releases of a specified project and workspace. The mata data +// includes the latest revision, and synopsis of the releases. +type releasesMetaData struct { + // LatestRevision of the Releases. + LatestRevision uint64 `yaml:"latestRevision,omitempty" json:"latestRevision,omitempty"` + + // ReleaseMetaDatas are the mata data of the Releases. + ReleaseMetaDatas []*releaseMetaData `yaml:"releaseMetaDatas,omitempty" json:"releaseMetaDatas,omitempty"` +} + +// releaseMetaData contains mata data of a specified release, which contains the Revision, Stack and Phase. +type releaseMetaData struct { + // Revision of the Release. + Revision uint64 + + // Stack of the Release. + Stack string + + // Phase of the Release. + Phase v1.ReleasePhase +} + +// checkRevisionExistence returns the workspace exists or not. +func checkRevisionExistence(meta *releasesMetaData, revision uint64) bool { + for _, metaData := range meta.ReleaseMetaDatas { + if revision == metaData.Revision { + return true + } + } + return false +} + +// getRevisions returns all the release revisions of a project and workspace. +func getRevisions(meta *releasesMetaData) []uint64 { + var revisions []uint64 + for _, release := range meta.ReleaseMetaDatas { + if release != nil { + revisions = append(revisions, release.Revision) + } + } + return revisions +} + +// getStackBoundRevisions returns the release revisions of a project, workspace and stack. +func getStackBoundRevisions(meta *releasesMetaData, stack string) []uint64 { + var revisions []uint64 + for _, release := range meta.ReleaseMetaDatas { + if release != nil && release.Stack == stack { + revisions = append(revisions, release.Revision) + } + } + return revisions +} + +// addLatestReleaseMetaData adds a release and updates the latest revision in the metadata, called +// by the storage.Create. +func addLatestReleaseMetaData(meta *releasesMetaData, revision uint64, stack string, phase v1.ReleasePhase) { + meta.LatestRevision = revision + metaData := &releaseMetaData{ + Revision: revision, + Stack: stack, + Phase: phase, + } + meta.ReleaseMetaDatas = append(meta.ReleaseMetaDatas, metaData) +} diff --git a/pkg/engine/release/storages/util_test.go b/pkg/engine/release/storages/util_test.go new file mode 100644 index 00000000..15d9ec7e --- /dev/null +++ b/pkg/engine/release/storages/util_test.go @@ -0,0 +1,179 @@ +package storages + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" +) + +func mockReleasesMetaData() *releasesMetaData { + return &releasesMetaData{ + LatestRevision: 3, + ReleaseMetaDatas: []*releaseMetaData{ + { + Revision: 1, + Stack: "dev", + Phase: v1.ReleasePhaseSucceeded, + }, + { + Revision: 2, + Stack: "pre", + Phase: v1.ReleasePhaseFailed, + }, + { + Revision: 3, + Stack: "pre", + Phase: v1.ReleasePhaseSucceeded, + }, + }, + } +} + +func TestCheckReleaseExistence(t *testing.T) { + testcases := []struct { + name string + meta *releasesMetaData + revision uint64 + exist bool + }{ + { + name: "empty releases meta data", + meta: &releasesMetaData{}, + revision: 2, + exist: false, + }, + { + name: "exist workspace", + meta: mockReleasesMetaData(), + revision: 2, + exist: true, + }, + { + name: "not exist workspace", + meta: mockReleasesMetaData(), + revision: 5, + exist: false, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + exist := checkRevisionExistence(tc.meta, tc.revision) + assert.Equal(t, tc.exist, exist) + }) + } +} + +func TestGetRevisions(t *testing.T) { + testcases := []struct { + name string + meta *releasesMetaData + expectedRevisions []uint64 + }{ + { + name: "get revisions", + meta: mockReleasesMetaData(), + expectedRevisions: []uint64{1, 2, 3}, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + revisions := getRevisions(tc.meta) + assert.Equal(t, tc.expectedRevisions, revisions) + }) + } +} + +func TestGetStackBoundRevisions(t *testing.T) { + testcases := []struct { + name string + meta *releasesMetaData + stack string + expectedRevisions []uint64 + }{ + { + name: "get stack bound revisions", + meta: mockReleasesMetaData(), + stack: "pre", + expectedRevisions: []uint64{2, 3}, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + revisions := getStackBoundRevisions(tc.meta, tc.stack) + assert.Equal(t, tc.expectedRevisions, revisions) + }) + } +} + +func TestAddLatestReleaseMetaData(t *testing.T) { + testcases := []struct { + name string + meta *releasesMetaData + revision uint64 + stack string + phase v1.ReleasePhase + expectedMeta *releasesMetaData + }{ + { + name: "empty releases meta data add release", + meta: &releasesMetaData{}, + revision: 1, + stack: "prod", + phase: v1.ReleasePhaseGenerating, + expectedMeta: &releasesMetaData{ + LatestRevision: 1, + ReleaseMetaDatas: []*releaseMetaData{ + { + Revision: 1, + Stack: "prod", + Phase: v1.ReleasePhaseGenerating, + }, + }, + }, + }, + { + name: "non-empty releases meta data add release", + meta: mockReleasesMetaData(), + revision: 4, + stack: "prod", + phase: v1.ReleasePhasePreviewing, + expectedMeta: &releasesMetaData{ + LatestRevision: 4, + ReleaseMetaDatas: []*releaseMetaData{ + { + Revision: 1, + Stack: "dev", + Phase: v1.ReleasePhaseSucceeded, + }, + { + Revision: 2, + Stack: "pre", + Phase: v1.ReleasePhaseFailed, + }, + { + Revision: 3, + Stack: "pre", + Phase: v1.ReleasePhaseSucceeded, + }, + { + Revision: 4, + Stack: "prod", + Phase: v1.ReleasePhasePreviewing, + }, + }, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + addLatestReleaseMetaData(tc.meta, tc.revision, tc.stack, tc.phase) + assert.Equal(t, tc.expectedMeta, tc.expectedMeta) + }) + } +}