diff --git a/pkg/apis/project/types.go b/pkg/apis/project/types.go index f87ef35e..f1eadbf1 100644 --- a/pkg/apis/project/types.go +++ b/pkg/apis/project/types.go @@ -7,7 +7,6 @@ import ( "github.com/pterm/pterm" "kusionstack.io/kusion/pkg/apis/stack" - "kusionstack.io/kusion/pkg/engine/backend" "kusionstack.io/kusion/pkg/log" ) @@ -50,9 +49,6 @@ type Configuration struct { // Tenant name Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` - // Backend storage config - Backend *backend.Storage `json:"backend,omitempty" yaml:"backend,omitempty"` - // SpecGenerator configs Generator *GeneratorConfig `json:"generator,omitempty" yaml:"generator,omitempty"` diff --git a/pkg/cmd/apply/options.go b/pkg/cmd/apply/options.go index c2ea3ff8..1ef1a5c7 100644 --- a/pkg/cmd/apply/options.go +++ b/pkg/cmd/apply/options.go @@ -91,8 +91,8 @@ func (o *Options) Run() error { return nil } - // Get state storage from backend config to manage state - stateStorage, err := backend.BackendFromConfig(project.Backend, o.BackendOps, o.WorkDir) + // Get state storage from cli backend options, environment variables, workspace backend configs + stateStorage, err := backend.NewStateStorage(stack, &o.BackendOptions) if err != nil { return err } diff --git a/pkg/cmd/apply/options_test.go b/pkg/cmd/apply/options_test.go index a13eaae8..9d0da6c6 100644 --- a/pkg/cmd/apply/options_test.go +++ b/pkg/cmd/apply/options_test.go @@ -188,7 +188,7 @@ func newSA(name string) intent.Resource { } func Test_apply(t *testing.T) { - stateStorage := &local.FileSystemState{Path: filepath.Join("", local.KusionState)} + stateStorage := &local.FileSystemState{Path: filepath.Join("", local.KusionStateFileFile)} mockey.PatchConvey("dry run", t, func() { planResources := &intent.Intent{Resources: []intent.Resource{sa1}} order := &opsmodels.ChangeOrder{ diff --git a/pkg/cmd/destroy/options.go b/pkg/cmd/destroy/options.go index 1224141a..b03e5c93 100644 --- a/pkg/cmd/destroy/options.go +++ b/pkg/cmd/destroy/options.go @@ -28,7 +28,7 @@ type Options struct { Operator string Yes bool Detail bool - backend.BackendOps + backend.BackendOptions } func NewDestroyOptions() *Options { @@ -42,7 +42,15 @@ func (o *Options) Complete(args []string) { } func (o *Options) Validate() error { - return o.Options.Validate() + if err := o.Options.Validate(); err != nil { + return err + } + if !o.BackendOptions.IsEmpty() { + if err := o.BackendOptions.Validate(); err != nil { + return err + } + } + return nil } func (o *Options) Run() error { @@ -54,8 +62,8 @@ func (o *Options) Run() error { return err } - // Get stateStorage from backend config to manage state - stateStorage, err := backend.BackendFromConfig(project.Backend, o.BackendOps, o.WorkDir) + // Get state storage from cli backend options, environment variables, workspace backend configs + stateStorage, err := backend.NewStateStorage(stack, &o.BackendOptions) if err != nil { return err } diff --git a/pkg/cmd/destroy/options_test.go b/pkg/cmd/destroy/options_test.go index 7e2f5f7d..863c0af0 100644 --- a/pkg/cmd/destroy/options_test.go +++ b/pkg/cmd/destroy/options_test.go @@ -100,7 +100,7 @@ func Test_preview(t *testing.T) { mockOperationPreview() o := NewDestroyOptions() - stateStorage := &local.FileSystemState{Path: filepath.Join(o.WorkDir, local.KusionState)} + stateStorage := &local.FileSystemState{Path: filepath.Join(o.WorkDir, local.KusionStateFileFile)} _, err := o.preview(&intent.Intent{Resources: []intent.Resource{sa1}}, p, s, stateStorage) assert.Nil(t, err) }) @@ -217,7 +217,7 @@ func Test_destroy(t *testing.T) { } changes := opsmodels.NewChanges(p, s, order) - stateStorage := &local.FileSystemState{Path: filepath.Join(o.WorkDir, local.KusionState)} + stateStorage := &local.FileSystemState{Path: filepath.Join(o.WorkDir, local.KusionStateFileFile)} err := o.destroy(planResources, changes, stateStorage) assert.Nil(t, err) @@ -239,7 +239,7 @@ func Test_destroy(t *testing.T) { }, } changes := opsmodels.NewChanges(p, s, order) - stateStorage := &local.FileSystemState{Path: filepath.Join(o.WorkDir, local.KusionState)} + stateStorage := &local.FileSystemState{Path: filepath.Join(o.WorkDir, local.KusionStateFileFile)} err := o.destroy(planResources, changes, stateStorage) assert.NotNil(t, err) diff --git a/pkg/cmd/preview/options.go b/pkg/cmd/preview/options.go index 3e684271..66318996 100644 --- a/pkg/cmd/preview/options.go +++ b/pkg/cmd/preview/options.go @@ -29,7 +29,7 @@ const jsonOutput = "json" type Options struct { build.Options Flags - backend.BackendOps + backend.BackendOptions } type Flags struct { @@ -62,6 +62,11 @@ func (o *Options) Validate() error { if err := o.ValidateIntentFile(); err != nil { return err } + if !o.BackendOptions.IsEmpty() { + if err := o.BackendOptions.Validate(); err != nil { + return err + } + } return nil } @@ -152,8 +157,8 @@ func (o *Options) Run() error { return nil } - // Get state storage from backend config to manage state - stateStorage, err := backend.BackendFromConfig(project.Backend, o.BackendOps, o.WorkDir) + // Get state storage from cli backend options, environment variables, workspace backend configs + stateStorage, err := backend.NewStateStorage(stack, &o.BackendOptions) if err != nil { return err } diff --git a/pkg/cmd/preview/options_test.go b/pkg/cmd/preview/options_test.go index 313b5c49..a09fde7e 100644 --- a/pkg/cmd/preview/options_test.go +++ b/pkg/cmd/preview/options_test.go @@ -47,7 +47,7 @@ var ( ) func Test_preview(t *testing.T) { - stateStorage := &local.FileSystemState{Path: filepath.Join("", local.KusionState)} + stateStorage := &local.FileSystemState{Path: filepath.Join("", local.KusionStateFileFile)} t.Run("preview success", func(t *testing.T) { m := mockOperationPreview() defer m.UnPatch() diff --git a/pkg/engine/backend/backend.go b/pkg/engine/backend/backend.go deleted file mode 100644 index ede90a70..00000000 --- a/pkg/engine/backend/backend.go +++ /dev/null @@ -1,127 +0,0 @@ -package backend - -import ( - "fmt" - "path/filepath" - "strings" - - "github.com/spf13/cobra" - "github.com/zclconf/go-cty/cty" - "github.com/zclconf/go-cty/cty/gocty" - - backendInit "kusionstack.io/kusion/pkg/engine/backend/init" - "kusionstack.io/kusion/pkg/engine/states" - "kusionstack.io/kusion/pkg/engine/states/local" - "kusionstack.io/kusion/pkg/util/i18n" -) - -// backend config state storage type -type Storage struct { - Type string `json:"storageType,omitempty" yaml:"storageType,omitempty"` - Config map[string]interface{} `json:"config,omitempty" yaml:"config,omitempty"` -} - -// BackendOps kusion cli backend override config -type BackendOps struct { - // Config is a series of backend configurations, - // such as ["path=kusion_state.json"] - Config []string - - // Type is the type of backend, currently supported: - // local - state is stored to a local file - // db - state is stored to db - // oss - state is stored to aliyun oss - // s3 - state is stored to aws s3 - Type string -} - -func (o *BackendOps) AddBackendFlags(cmd *cobra.Command) { - cmd.Flags().StringVar(&o.Type, "backend-type", "", - i18n.T("backend-type specify state storage backend")) - cmd.Flags().StringSliceVarP(&o.Config, "backend-config", "C", []string{}, - i18n.T("backend-config config state storage backend")) -} - -// MergeConfig merge project backend config and cli backend config -func MergeConfig(config, override map[string]interface{}) map[string]interface{} { - content := make(map[string]interface{}) - for k, v := range config { - content[k] = v - } - for k, v := range override { - content[k] = v - } - return content -} - -// NewDefaultBackend return default backend, default backend is local filesystem -func NewDefaultBackend(dir string, fileName string) *Storage { - return &Storage{ - Type: "local", - Config: map[string]interface{}{ - "path": filepath.Join(dir, fileName), - }, - } -} - -// BackendFromConfig return stateStorage, this func handler -// backend config merge and configure backend. -// return a StateStorage to manage State -func BackendFromConfig(config *Storage, override BackendOps, dir string) (states.StateStorage, error) { - var backendConfig Storage - if config == nil { - config = NewDefaultBackend(dir, local.KusionState) - } - if config.Type != "" { - backendConfig.Type = config.Type - } - - if override.Type != "" { - backendConfig.Type = override.Type - } - configOverride := make(map[string]interface{}) - for _, v := range override.Config { - bk := strings.Split(v, "=") - if len(bk) != 2 { - return nil, fmt.Errorf("kusion cli backend config should be path=kusion_state.json") - } - configOverride[bk[0]] = bk[1] - } - if config.Config != nil || override.Config != nil { - backendConfig.Config = MergeConfig(config.Config, configOverride) - } - - backendFunc := backendInit.GetBackend(backendConfig.Type) - if backendFunc == nil { - return nil, fmt.Errorf("kusion backend storage: %s not support, please check storageType config", backendConfig.Type) - } - - bf := backendFunc() - - backendSchema := bf.ConfigSchema() - err := validBackendConfig(backendConfig.Config, backendSchema) - if err != nil { - return nil, err - } - ctyBackend, err := gocty.ToCtyValue(backendConfig.Config, backendSchema) - if err != nil { - return nil, err - } - - err = bf.Configure(ctyBackend) - if err != nil { - return nil, err - } - - return bf.StateStorage(), nil -} - -// validBackendConfig check backend config. -func validBackendConfig(config map[string]interface{}, schema cty.Type) error { - for k := range config { - if !schema.HasAttribute(k) { - return fmt.Errorf("not support %s in backend config", k) - } - } - return nil -} diff --git a/pkg/engine/backend/backend_test.go b/pkg/engine/backend/backend_test.go deleted file mode 100644 index d7d3d818..00000000 --- a/pkg/engine/backend/backend_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package backend - -import ( - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/zclconf/go-cty/cty" - - _ "kusionstack.io/kusion/pkg/engine/backend/init" - "kusionstack.io/kusion/pkg/engine/states" - "kusionstack.io/kusion/pkg/engine/states/local" -) - -func TestMergeConfig(t *testing.T) { - type args struct { - config map[string]interface{} - override map[string]interface{} - } - type want struct { - content map[string]interface{} - } - - tests := map[string]struct { - args - want - }{ - "MergeConfig": { - args: args{ - config: map[string]interface{}{ - "path": "kusion_state.json", - }, - override: map[string]interface{}{ - "config": "kusion_config.json", - }, - }, - want: want{ - content: map[string]interface{}{ - "path": "kusion_state.json", - "config": "kusion_config.json", - }, - }, - }, - } - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - mergeConfig := MergeConfig(tt.config, tt.override) - if diff := cmp.Diff(tt.want.content, mergeConfig); diff != "" { - t.Errorf("\nWrapMergeConfigFailed(...): -want message, +got message:\n%s", diff) - } - }) - } -} - -func TestBackendFromConfig(t *testing.T) { - type args struct { - config *Storage - override BackendOps - } - type want struct { - storage states.StateStorage - err error - } - tests := map[string]struct { - args - want - }{ - "BackendFromConfig": { - args: args{ - config: &Storage{ - Type: "local", - Config: map[string]interface{}{ - "path": "kusion_state.json", - }, - }, - override: BackendOps{ - Config: []string{ - "path=kusion_local.json", - }, - }, - }, - want: want{ - storage: &local.FileSystemState{Path: "kusion_local.json"}, - err: nil, - }, - }, - } - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - storage, _ := BackendFromConfig(tt.config, tt.override, "./") - if diff := cmp.Diff(tt.want.storage, storage); diff != "" { - t.Errorf("\nWrapBackendFromConfigFailed(...): -want message, +got message:\n%s", diff) - } - }) - } -} - -func TestValidBackendConfig(t *testing.T) { - type args struct { - config map[string]interface{} - schema cty.Type - } - type want struct { - errMsg string - } - tests := map[string]struct { - args - want - }{ - "InValidBackendConfig": { - args: args{ - config: map[string]interface{}{ - "kusionPath": "kusion_state.json", - }, - schema: cty.Object(map[string]cty.Type{"path": cty.String}), - }, - want: want{ - errMsg: "not support kusionPath in backend config", - }, - }, - } - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - err := validBackendConfig(tt.config, tt.schema) - if diff := cmp.Diff(tt.want.errMsg, err.Error()); diff != "" { - t.Errorf("\nWrapvalidBackendConfigFailed(...): -want message, +got message:\n%s", diff) - } - }) - } -} diff --git a/pkg/engine/backend/config.go b/pkg/engine/backend/config.go new file mode 100644 index 00000000..c2d4b0d6 --- /dev/null +++ b/pkg/engine/backend/config.go @@ -0,0 +1,171 @@ +package backend + +import ( + "fmt" + "path" + + "github.com/zclconf/go-cty/cty/gocty" + + workspaceapi "kusionstack.io/kusion/pkg/apis/workspace" + backendinit "kusionstack.io/kusion/pkg/engine/backend/init" + "kusionstack.io/kusion/pkg/engine/states" + "kusionstack.io/kusion/pkg/engine/states/local" + "kusionstack.io/kusion/pkg/workspace" +) + +// StateStorageConfig contains backend config for state storage. +type StateStorageConfig struct { + Type string + Config map[string]any +} + +// NewConfig news a StateStorageConfig from workspace BackendConfigs, BackendOptions and environment variables. +func NewConfig(workDir string, configs *workspaceapi.BackendConfigs, opts *BackendOptions) (*StateStorageConfig, error) { + var config, overrideConfig *StateStorageConfig + config = convertWorkspaceBackendConfig(workDir, configs) + if opts != nil && !opts.IsEmpty() { + var err error + if overrideConfig, err = opts.toStateStorageConfig(); err != nil { + return nil, err + } + } + + backendType := config.Type + if overrideConfig != nil && overrideConfig.Type != backendType { + backendType = overrideConfig.Type + } + envConfig := getEnvBackendConfig(backendType) + return mergeConfig(backendType, config, overrideConfig, envConfig), nil +} + +// NewDefaultStateStorageConfig news the default state storage which uses local backend. +func NewDefaultStateStorageConfig(workDir string) *StateStorageConfig { + return &StateStorageConfig{ + Type: workspaceapi.BackendLocal, + Config: map[string]any{ + "path": path.Join(workDir, local.KusionStateFileFile), + }, + } +} + +// NewStateStorage news a StateStorage using the StateStorageConfig. +func (c *StateStorageConfig) NewStateStorage() (states.StateStorage, error) { + backendFunc := backendinit.GetBackend(c.Type) + if backendFunc == nil { + return nil, fmt.Errorf("do not support state backend type %s", c.Type) + } + bf := backendFunc() + backendSchema := bf.ConfigSchema() + ctyBackend, err := gocty.ToCtyValue(c.Config, backendSchema) + if err != nil { + return nil, err + } + err = bf.Configure(ctyBackend) + if err != nil { + return nil, err + } + return bf.StateStorage(), nil +} + +// convertWorkspaceBackendConfig converts workspace backend config to StateStorageConfig. +func convertWorkspaceBackendConfig(workDir string, configs *workspaceapi.BackendConfigs) *StateStorageConfig { + name := workspace.GetBackendName(configs) + var config map[string]any + switch name { + case workspaceapi.BackendLocal: + config = NewDefaultStateStorageConfig(workDir).Config + case workspaceapi.BackendMysql: + config = map[string]any{ + "dbName": configs.Mysql.DBName, + "user": configs.Mysql.User, + "password": configs.Mysql.Password, + "host": configs.Mysql.Host, + "port": *configs.Mysql.Port, + } + case workspaceapi.BackendOss: + config = map[string]any{ + "endpoint": configs.Oss.Endpoint, + "bucket": configs.Oss.Bucket, + "accessKeyID": configs.Oss.AccessKeyID, + "accessKeySecret": configs.Oss.AccessKeySecret, + } + case workspaceapi.BackendS3: + config = map[string]any{ + "endpoint": configs.S3.Endpoint, + "bucket": configs.S3.Bucket, + "accessKeyID": configs.S3.AccessKeyID, + "accessKeySecret": configs.S3.AccessKeySecret, + "region": configs.S3.Region, + } + } + return &StateStorageConfig{ + Type: name, + Config: config, + } +} + +// getEnvBackendConfig gets specified backend config set by environment variables +func getEnvBackendConfig(backendType string) map[string]any { + config := make(map[string]any) + switch backendType { + case workspaceapi.BackendMysql: + password := workspace.GetMysqlPasswordFromEnv() + if password != "" { + config["password"] = password + } + case workspaceapi.BackendOss: + accessKeyID, accessKeySecret := workspace.GetOssSensitiveDataFromEnv() + if accessKeyID != "" { + config["accessKeyID"] = accessKeyID + } + if accessKeySecret != "" { + config["accessKeySecret"] = accessKeySecret + } + case workspaceapi.BackendS3: + accessKeyID, accessKeySecret, region := workspace.GetS3SensitiveDataFromEnv() + if accessKeyID != "" { + config["accessKeyID"] = accessKeyID + } + if accessKeySecret != "" { + config["accessKeySecret"] = accessKeySecret + } + if region != "" { + config["region"] = region + } + } + return config +} + +// mergeConfig merges the cli backend config (overrideConfig), environment variables (envConfig), and +// workspace backend config (config) in descending order of priority, to generate the StateStorageConfig +// which is used to new the StateStorage. +func mergeConfig(backendType string, config, overrideConfig *StateStorageConfig, envConfig map[string]any) *StateStorageConfig { + var useConfig, useOverride bool + if overrideConfig == nil { + useConfig = true + } else if overrideConfig.Type == config.Type { + useConfig = true + useOverride = true + } else { + useOverride = true + } + + mergedConfig := &StateStorageConfig{ + Type: backendType, + Config: make(map[string]any), + } + if useConfig { + for k, v := range config.Config { + mergedConfig.Config[k] = v + } + } + for k, v := range envConfig { + mergedConfig.Config[k] = v + } + if useOverride { + for k, v := range overrideConfig.Config { + mergedConfig.Config[k] = v + } + } + return mergedConfig +} diff --git a/pkg/engine/backend/config_test.go b/pkg/engine/backend/config_test.go new file mode 100644 index 00000000..f0d4571f --- /dev/null +++ b/pkg/engine/backend/config_test.go @@ -0,0 +1,258 @@ +package backend + +import ( + "os" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + + "kusionstack.io/kusion/pkg/apis/workspace" + "kusionstack.io/kusion/pkg/engine/states" + "kusionstack.io/kusion/pkg/engine/states/local" +) + +func TestNewConfig(t *testing.T) { + mysqlPort := 3306 + testcases := []struct { + name string + success bool + workDir string + configs *workspace.BackendConfigs + opts *BackendOptions + setEnvFunc, unSetEnvFunc func() + expectedConfig *StateStorageConfig + }{ + { + name: "default config", + success: true, + workDir: "/test_project/test_stack", + configs: nil, + opts: &BackendOptions{}, + setEnvFunc: nil, + unSetEnvFunc: nil, + expectedConfig: &StateStorageConfig{ + Type: workspace.BackendLocal, + Config: map[string]any{ + "path": "/test_project/test_stack/kusion_state.yaml", + }, + }, + }, + { + name: "empty backend options", + success: true, + workDir: "/testProject/testStack", + configs: &workspace.BackendConfigs{ + Mysql: &workspace.MysqlConfig{ + DBName: "kusion_db", + User: "kusion", + Password: "do_not_recommend", + Host: "127.0.0.1", + Port: &mysqlPort, + }, + }, + opts: &BackendOptions{}, + setEnvFunc: func() { + _ = os.Setenv(workspace.EnvBackendMysqlPassword, "kusion_password") + }, + unSetEnvFunc: func() { + _ = os.Unsetenv(workspace.EnvBackendMysqlPassword) + }, + expectedConfig: &StateStorageConfig{ + Type: workspace.BackendMysql, + Config: map[string]any{ + "dbName": "kusion_db", + "user": "kusion", + "password": "kusion_password", + "host": "127.0.0.1", + "port": 3306, + }, + }, + }, + { + name: "backend options override", + success: true, + workDir: "/testProject/testStack", + configs: &workspace.BackendConfigs{ + Mysql: &workspace.MysqlConfig{ + DBName: "kusion_db", + User: "kusion", + Host: "127.0.0.1", + Port: &mysqlPort, + }, + }, + opts: &BackendOptions{ + Type: workspace.BackendS3, + Config: []string{"region=ua-east-2", "bucket=kusion_bucket"}, + }, + setEnvFunc: func() { + _ = os.Setenv(workspace.EnvAwsRegion, "ua-east-1") + _ = os.Setenv(workspace.EnvAwsAccessKeyID, "aws_ak_id") + _ = os.Setenv(workspace.EnvAwsSecretAccessKey, "aws_ak_secret") + }, + unSetEnvFunc: func() { + _ = os.Unsetenv(workspace.EnvAwsDefaultRegion) + _ = os.Unsetenv(workspace.EnvOssAccessKeyID) + _ = os.Unsetenv(workspace.EnvAwsSecretAccessKey) + }, + expectedConfig: &StateStorageConfig{ + Type: workspace.BackendS3, + Config: map[string]any{ + "region": "ua-east-2", + "accessKeyID": "aws_ak_id", + "accessKeySecret": "aws_ak_secret", + "bucket": "kusion_bucket", + }, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + if tc.setEnvFunc != nil { + tc.setEnvFunc() + } + config, err := NewConfig(tc.workDir, tc.configs, tc.opts) + if tc.unSetEnvFunc != nil { + tc.unSetEnvFunc() + } + assert.Equal(t, tc.success, err == nil) + assert.Equal(t, *tc.expectedConfig, *config) + }) + } +} + +func TestStateStorageConfig_NewStateStorage(t *testing.T) { + testcases := []struct { + name string + success bool + config *StateStorageConfig + expectedStateStorage states.StateStorage + }{ + { + name: "local state storage", + success: true, + config: &StateStorageConfig{ + Type: workspace.BackendLocal, + Config: map[string]any{ + "path": "/test_project/test_stack/kusion_state.yaml", + }, + }, + expectedStateStorage: &local.FileSystemState{ + Path: "/test_project/test_stack/kusion_state.yaml", + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + stateStorage, err := tc.config.NewStateStorage() + assert.Equal(t, tc.success, err == nil) + assert.True(t, reflect.DeepEqual(tc.expectedStateStorage, stateStorage)) + }) + } +} + +func TestMergeConfig(t *testing.T) { + testcases := []struct { + name string + backendType string + config, overrideConfig *StateStorageConfig + envConfig map[string]any + mergedConfig *StateStorageConfig + }{ + { + name: "empty override config", + backendType: workspace.BackendLocal, + config: &StateStorageConfig{ + Type: workspace.BackendLocal, + Config: map[string]any{ + "path": "/test_project/test_stack/kusion_state.yaml", + }, + }, + overrideConfig: nil, + envConfig: nil, + mergedConfig: &StateStorageConfig{ + Type: workspace.BackendLocal, + Config: map[string]any{ + "path": "/test_project/test_stack/kusion_state.yaml", + }, + }, + }, + { + name: "same type override config", + backendType: workspace.BackendMysql, + config: &StateStorageConfig{ + Type: workspace.BackendMysql, + Config: map[string]any{ + "dbName": "kusion_db", + "user": "kusion", + "host": "127.0.0.1", + "port": 3306, + }, + }, + overrideConfig: &StateStorageConfig{ + Type: workspace.BackendMysql, + Config: map[string]any{ + "dbName": "new_kusion_db", + "user": "new_kusion", + }, + }, + envConfig: map[string]any{ + "password": "new_kusion_password", + }, + mergedConfig: &StateStorageConfig{ + Type: workspace.BackendMysql, + Config: map[string]any{ + "dbName": "new_kusion_db", + "user": "new_kusion", + "password": "new_kusion_password", + "host": "127.0.0.1", + "port": 3306, + }, + }, + }, + { + name: "different type override config", + backendType: workspace.BackendOss, + config: &StateStorageConfig{ + Type: workspace.BackendMysql, + Config: map[string]any{ + "dbName": "kusion_db", + "user": "kusion", + "host": "127.0.0.1", + "port": 3306, + }, + }, + overrideConfig: &StateStorageConfig{ + Type: workspace.BackendOss, + Config: map[string]any{ + "endpoint": "oss-cn-hangzhou.aliyuncs.com", + "bucket": "kusion_test", + "accessKeyID": "kusion_test", + "accessKeySecret": "kusion_test", + }, + }, + envConfig: map[string]any{ + "accessKeyID": "kusion_test_env", + "accessKeySecret": "kusion_test_env", + }, + mergedConfig: &StateStorageConfig{ + Type: workspace.BackendOss, + Config: map[string]any{ + "endpoint": "oss-cn-hangzhou.aliyuncs.com", + "bucket": "kusion_test", + "accessKeyID": "kusion_test", + "accessKeySecret": "kusion_test", + }, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + config := mergeConfig(tc.backendType, tc.config, tc.overrideConfig, tc.envConfig) + assert.Equal(t, *tc.mergedConfig, *config) + }) + } +} diff --git a/pkg/engine/backend/init/init.go b/pkg/engine/backend/init/init.go index 21064a74..dfabec34 100644 --- a/pkg/engine/backend/init/init.go +++ b/pkg/engine/backend/init/init.go @@ -1,10 +1,11 @@ package init import ( + "kusionstack.io/kusion/pkg/apis/workspace" "kusionstack.io/kusion/pkg/engine/states" "kusionstack.io/kusion/pkg/engine/states/local" - "kusionstack.io/kusion/pkg/engine/states/remote/db" "kusionstack.io/kusion/pkg/engine/states/remote/http" + "kusionstack.io/kusion/pkg/engine/states/remote/mysql" "kusionstack.io/kusion/pkg/engine/states/remote/oss" "kusionstack.io/kusion/pkg/engine/states/remote/s3" ) @@ -15,11 +16,11 @@ var backends map[string]func() states.Backend // init backends map with all support backend func init() { backends = map[string]func() states.Backend{ - "local": local.NewLocalBackend, - "db": db.NewDBBackend, - "oss": oss.NewOssBackend, - "s3": s3.NewS3Backend, - "http": http.NewHTTPBackend, + workspace.BackendLocal: local.NewLocalBackend, + workspace.BackendMysql: mysql.NewMysqlBackend, + workspace.BackendOss: oss.NewOssBackend, + workspace.BackendS3: s3.NewS3Backend, + "http": http.NewHTTPBackend, } } diff --git a/pkg/engine/backend/options.go b/pkg/engine/backend/options.go new file mode 100644 index 00000000..cf8b77d5 --- /dev/null +++ b/pkg/engine/backend/options.go @@ -0,0 +1,108 @@ +package backend + +import ( + "errors" + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/zclconf/go-cty/cty" + + "kusionstack.io/kusion/pkg/apis/workspace" + backendinit "kusionstack.io/kusion/pkg/engine/backend/init" + "kusionstack.io/kusion/pkg/util/i18n" +) + +var ( + ErrEmptyBackendType = errors.New("empty --backend-type") + ErrUnsupportedBackendType = errors.New("unsupported --backend-type") + ErrInvalidBackendConfigFormat = errors.New("invalid --backend-config format, should with format [\"=\"]") + ErrEmptyBackendConfigKey = errors.New("empty config key in --backend-config item") + ErrEmptyBackendConfigValue = errors.New("empty config value in --backend-config item") + ErrUnsupportedBackendConfigItem = errors.New("unsupported --backend-config item") + ErrNotSupportBackendConfig = errors.New("do not support --backend-config") +) + +// BackendOptions is the kusion cli backend override config +type BackendOptions struct { + // Type is the type of backend, currently supported: + // local - state is stored to a local file + // mysql - state is stored to mysql + // oss - state is stored to aliyun oss + // s3 - state is stored to aws s3 + // http - state is stored by http service + Type string + + // Config is a group of configurations of the specified type backend, each configuration item with + // the format "key=value", such as "dbName=kusion-db" for type mysql + Config []string +} + +func (o *BackendOptions) AddBackendFlags(cmd *cobra.Command) { + cmd.Flags().StringVar(&o.Type, "backend-type", "", + i18n.T("backend-type specify state storage backend")) + cmd.Flags().StringSliceVarP(&o.Config, "backend-config", "C", []string{}, + i18n.T("backend-config config state storage backend")) +} + +// IsEmpty returns the BackendOptions is empty or not. +func (o *BackendOptions) IsEmpty() bool { + return o.Type == "" && len(o.Config) == 0 +} + +// Validate checks the BackendOptions is valid or not. +func (o *BackendOptions) Validate() error { + if o.Type == "" { + return ErrEmptyBackendType + } + backendFunc := backendinit.GetBackend(o.Type) + if backendFunc == nil { + return ErrUnsupportedBackendType + } + config, err := o.toStateStorageConfig() + if err != nil { + return err + } + backendSchema := backendFunc().ConfigSchema() + if err = validBackendConfig(config, backendSchema); err != nil { + return err + } + return nil +} + +// toStateStorageConfig converts BackendOptions to StateStorageConfig. +func (o *BackendOptions) toStateStorageConfig() (*StateStorageConfig, error) { + config := make(map[string]any) + for _, v := range o.Config { + bk := strings.Split(v, "=") + if len(bk) != 2 { + return nil, ErrInvalidBackendConfigFormat + } + if bk[0] == "" { + return nil, ErrEmptyBackendConfigKey + } + if bk[1] == "" { + return nil, ErrEmptyBackendConfigValue + } + config[bk[0]] = bk[1] + } + + return &StateStorageConfig{ + Type: o.Type, + Config: config, + }, nil +} + +// validBackendConfig checks state backend config from BackendOptions, it only checks whether there are +// unsupported backend configuration items for now. +func validBackendConfig(config *StateStorageConfig, schema cty.Type) error { + for k := range config.Config { + if !schema.HasAttribute(k) { + return fmt.Errorf("%w: %s", ErrUnsupportedBackendConfigItem, k) + } + } + if config.Type == workspace.BackendLocal && len(config.Config) != 0 { + return fmt.Errorf("%w for backend local", ErrNotSupportBackendConfig) + } + return nil +} diff --git a/pkg/engine/backend/options_test.go b/pkg/engine/backend/options_test.go new file mode 100644 index 00000000..9f6e5523 --- /dev/null +++ b/pkg/engine/backend/options_test.go @@ -0,0 +1,94 @@ +package backend + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "kusionstack.io/kusion/pkg/apis/workspace" + _ "kusionstack.io/kusion/pkg/engine/backend/init" +) + +func TestBackendOptions_Validate(t *testing.T) { + testcases := []struct { + name string + success bool + opts *BackendOptions + }{ + { + name: "valid backend options", + success: true, + opts: &BackendOptions{ + Type: workspace.BackendMysql, + Config: []string{ + "dbName=kusion_db", + "user=kusion", + "password=kusion_password", + "host=127.0.0.1", + "port=3306", + }, + }, + }, + { + name: "invalid backend options empty type", + success: false, + opts: &BackendOptions{ + Type: "", + }, + }, + { + name: "invalid backend options unsupported type", + success: false, + opts: &BackendOptions{ + Type: "unsupported type", + }, + }, + { + name: "invalid backend options invalid config format", + success: false, + opts: &BackendOptions{ + Type: "mysql", + Config: []string{"dbName:kusion_db"}, + }, + }, + { + name: "invalid backend options empty config key", + success: false, + opts: &BackendOptions{ + Type: "mysql", + Config: []string{"=kusion_db"}, + }, + }, + { + name: "invalid backend options empty config value", + success: false, + opts: &BackendOptions{ + Type: "mysql", + Config: []string{"dbName="}, + }, + }, + { + name: "invalid backend options unsupported config item", + success: false, + opts: &BackendOptions{ + Type: "mysql", + Config: []string{"unsupported_dbName=kusion_db"}, + }, + }, + { + name: "invalid backend options unsupported local backend config", + success: false, + opts: &BackendOptions{ + Type: "local", + Config: []string{"path=unsupported_kusion_state.yaml"}, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + err := tc.opts.Validate() + assert.Equal(t, tc.success, err == nil) + }) + } +} diff --git a/pkg/engine/backend/testdata/workspaces/invalid_backend_ws.yaml b/pkg/engine/backend/testdata/workspaces/invalid_backend_ws.yaml new file mode 100644 index 00000000..ae4e5876 --- /dev/null +++ b/pkg/engine/backend/testdata/workspaces/invalid_backend_ws.yaml @@ -0,0 +1,28 @@ +modules: + database: + default: + instanceType: db.t3.micro + type: aws + version: "5.7" + smallClass: + instanceType: db.t3.small + projectSelector: + - foo + - bar + port: + default: + type: aws +runtimes: + kubernetes: + kubeConfig: /etc/kubeconfig.yaml + terraform: + aws: + region: us-east-1 + source: hashicorp/aws + version: 1.0.4 +backends: + oss: + bucket: kusion-bucket + s3: + region: ua-east-1 + bucket: kusion-bucket \ No newline at end of file diff --git a/pkg/engine/backend/testdata/workspaces/invalid_ws.yaml b/pkg/engine/backend/testdata/workspaces/invalid_ws.yaml new file mode 100644 index 00000000..0eb02d10 --- /dev/null +++ b/pkg/engine/backend/testdata/workspaces/invalid_ws.yaml @@ -0,0 +1 @@ +invalid ws \ No newline at end of file diff --git a/pkg/engine/backend/testdata/workspaces/s3_backend_ws.yaml b/pkg/engine/backend/testdata/workspaces/s3_backend_ws.yaml new file mode 100644 index 00000000..fba1bb15 --- /dev/null +++ b/pkg/engine/backend/testdata/workspaces/s3_backend_ws.yaml @@ -0,0 +1,26 @@ +modules: + database: + default: + instanceType: db.t3.micro + type: aws + version: "5.7" + smallClass: + instanceType: db.t3.small + projectSelector: + - foo + - bar + port: + default: + type: aws +runtimes: + kubernetes: + kubeConfig: /etc/kubeconfig.yaml + terraform: + aws: + region: us-east-1 + source: hashicorp/aws + version: 1.0.4 +backends: + s3: + region: ua-east-1 + bucket: kusion-bucket diff --git a/pkg/engine/backend/util.go b/pkg/engine/backend/util.go new file mode 100644 index 00000000..8ff7fa46 --- /dev/null +++ b/pkg/engine/backend/util.go @@ -0,0 +1,37 @@ +package backend + +import ( + "fmt" + + "kusionstack.io/kusion/pkg/apis/stack" + workspaceapi "kusionstack.io/kusion/pkg/apis/workspace" + "kusionstack.io/kusion/pkg/engine/states" + "kusionstack.io/kusion/pkg/workspace" +) + +// NewStateStorage news a StateStorage by configs of workspace, cli backend options, and environment variables. +func NewStateStorage(stack *stack.Stack, opts *BackendOptions) (states.StateStorage, error) { + var backendConfigs *workspaceapi.BackendConfigs + wsOperator, err := workspace.NewValidDefaultOperator() + if err != nil { + return nil, fmt.Errorf("new default workspace opearator failed, %w", err) + } + if wsOperator.WorkspaceExist(stack.Name) { + var ws *workspaceapi.Workspace + ws, err = wsOperator.GetWorkspace(stack.Name) + if err != nil { + return nil, fmt.Errorf("get config of workspace %s failed, %w", stack.Name, err) + } + backendConfigs = ws.Backends + if backendConfigs != nil { + if err = workspace.ValidateBackendConfigs(backendConfigs); err != nil { + return nil, fmt.Errorf("invalid backend configs of workspace %s, %w", stack.Name, err) + } + } + } + stateStorageConfig, err := NewConfig(stack.Path, backendConfigs, opts) + if err != nil { + return nil, err + } + return stateStorageConfig.NewStateStorage() +} diff --git a/pkg/engine/backend/util_test.go b/pkg/engine/backend/util_test.go new file mode 100644 index 00000000..fa9c551c --- /dev/null +++ b/pkg/engine/backend/util_test.go @@ -0,0 +1,103 @@ +package backend + +import ( + "fmt" + "os" + "path" + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + + "kusionstack.io/kusion/pkg/apis/stack" + "kusionstack.io/kusion/pkg/apis/workspace" + "kusionstack.io/kusion/pkg/util/kfile" +) + +func testDataFolder() string { + pwd, _ := os.Getwd() + return path.Join(pwd, "testdata") +} + +func mockStack(name string) *stack.Stack { + return &stack.Stack{ + Configuration: stack.Configuration{ + Name: name, + }, + Path: fmt.Sprintf("/test_project/%s", name), + } +} + +func Test_NewStateStorage(t *testing.T) { + testcases := []struct { + name string + success bool + stack *stack.Stack + opts *BackendOptions + setEnvFunc, unsetEnvFunc func() + }{ + { + name: "default state storage not exist workspace", + success: true, + stack: mockStack("empty_backend_ws_not_exist"), + opts: &BackendOptions{}, + }, + { + name: "oss state storage use workspace", + success: true, + stack: mockStack("s3_backend_ws"), + opts: &BackendOptions{}, + setEnvFunc: func() { + _ = os.Setenv(workspace.EnvAwsRegion, "ua-east-2") + _ = os.Setenv(workspace.EnvAwsAccessKeyID, "aws_ak_id") + _ = os.Setenv(workspace.EnvAwsSecretAccessKey, "aws_ak_secret") + }, + unsetEnvFunc: func() { + _ = os.Unsetenv(workspace.EnvAwsDefaultRegion) + _ = os.Unsetenv(workspace.EnvOssAccessKeyID) + _ = os.Unsetenv(workspace.EnvAwsSecretAccessKey) + }, + }, + { + name: "invalid workspace", + success: false, + stack: mockStack("invalid_ws"), + opts: &BackendOptions{}, + setEnvFunc: nil, + unsetEnvFunc: nil, + }, + { + name: "invalid backend config", + success: false, + stack: mockStack("invalid_backend_ws"), + opts: &BackendOptions{}, + setEnvFunc: nil, + unsetEnvFunc: nil, + }, + { + name: "invalid options", + success: false, + stack: mockStack("not_exist_ws"), + opts: &BackendOptions{Type: "not_support"}, + setEnvFunc: nil, + unsetEnvFunc: nil, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + mockey.PatchConvey("mock kusion data folder", t, func() { + mockey.Mock(kfile.KusionDataFolder).Return(testDataFolder(), nil).Build() + + if tc.setEnvFunc != nil { + tc.setEnvFunc() + } + _, err := NewStateStorage(tc.stack, tc.opts) + if tc.unsetEnvFunc != nil { + tc.unsetEnvFunc() + } + assert.Equal(t, tc.success, err == nil) + }) + }) + } +} diff --git a/pkg/engine/operation/apply_test.go b/pkg/engine/operation/apply_test.go index 10b0f4ea..5d2f5529 100644 --- a/pkg/engine/operation/apply_test.go +++ b/pkg/engine/operation/apply_test.go @@ -116,9 +116,8 @@ func TestOperation_Apply(t *testing.T) { } p := &project.Project{ Configuration: project.Configuration{ - Name: "fakeProject", - Tenant: "fakeTenant", - Backend: nil, + Name: "fakeProject", + Tenant: "fakeTenant", }, Path: "fakePath", Stacks: []*stack.Stack{s}, @@ -135,7 +134,7 @@ func TestOperation_Apply(t *testing.T) { name: "apply test", fields: fields{ OperationType: opsmodels.Apply, - StateStorage: &local.FileSystemState{Path: filepath.Join("test_data", local.KusionState)}, + StateStorage: &local.FileSystemState{Path: filepath.Join("test_data", local.KusionStateFileFile)}, RuntimeMap: map[intent.Type]runtime.Runtime{runtime.Kubernetes: &kubernetes.KubernetesRuntime{}}, MsgCh: make(chan opsmodels.Message, 5), }, diff --git a/pkg/engine/operation/destory_test.go b/pkg/engine/operation/destory_test.go index 18a55b4b..7773f548 100644 --- a/pkg/engine/operation/destory_test.go +++ b/pkg/engine/operation/destory_test.go @@ -34,9 +34,8 @@ func TestOperation_Destroy(t *testing.T) { } p := &project.Project{ Configuration: project.Configuration{ - Name: "fake-name", - Tenant: "fake-tenant", - Backend: nil, + Name: "fake-name", + Tenant: "fake-tenant", }, Path: "fake-path", Stacks: []*stack.Stack{s}, @@ -54,7 +53,7 @@ func TestOperation_Destroy(t *testing.T) { o := &DestroyOperation{ opsmodels.Operation{ OperationType: opsmodels.Destroy, - StateStorage: &local.FileSystemState{Path: filepath.Join("test_data", local.KusionState)}, + StateStorage: &local.FileSystemState{Path: filepath.Join("test_data", local.KusionStateFileFile)}, RuntimeMap: map[intent.Type]runtime.Runtime{runtime.Kubernetes: &kubernetes.KubernetesRuntime{}}, }, } diff --git a/pkg/engine/operation/preview_test.go b/pkg/engine/operation/preview_test.go index d27e8922..6eaf19f8 100644 --- a/pkg/engine/operation/preview_test.go +++ b/pkg/engine/operation/preview_test.go @@ -108,9 +108,8 @@ func TestOperation_Preview(t *testing.T) { } p := &project.Project{ Configuration: project.Configuration{ - Name: "fake-name", - Tenant: "fake-tenant", - Backend: nil, + Name: "fake-name", + Tenant: "fake-tenant", }, Path: "fake-path", Stacks: []*stack.Stack{s}, @@ -127,7 +126,7 @@ func TestOperation_Preview(t *testing.T) { fields: fields{ OperationType: opsmodels.ApplyPreview, RuntimeMap: map[intent.Type]runtime.Runtime{runtime.Kubernetes: &fakePreviewRuntime{}}, - StateStorage: &local.FileSystemState{Path: local.KusionState}, + StateStorage: &local.FileSystemState{Path: local.KusionStateFileFile}, Order: &opsmodels.ChangeOrder{StepKeys: []string{}, ChangeSteps: map[string]*opsmodels.ChangeStep{}}, }, args: args{ @@ -165,7 +164,7 @@ func TestOperation_Preview(t *testing.T) { fields: fields{ OperationType: opsmodels.DestroyPreview, RuntimeMap: map[intent.Type]runtime.Runtime{runtime.Kubernetes: &fakePreviewRuntime{}}, - StateStorage: &local.FileSystemState{Path: local.KusionState}, + StateStorage: &local.FileSystemState{Path: local.KusionStateFileFile}, Order: &opsmodels.ChangeOrder{}, }, args: args{ @@ -203,7 +202,7 @@ func TestOperation_Preview(t *testing.T) { fields: fields{ OperationType: opsmodels.ApplyPreview, RuntimeMap: map[intent.Type]runtime.Runtime{runtime.Kubernetes: &fakePreviewRuntime{}}, - StateStorage: &local.FileSystemState{Path: local.KusionState}, + StateStorage: &local.FileSystemState{Path: local.KusionStateFileFile}, Order: &opsmodels.ChangeOrder{}, }, args: args{ @@ -221,7 +220,7 @@ func TestOperation_Preview(t *testing.T) { fields: fields{ OperationType: opsmodels.ApplyPreview, RuntimeMap: map[intent.Type]runtime.Runtime{runtime.Kubernetes: &fakePreviewRuntime{}}, - StateStorage: &local.FileSystemState{Path: local.KusionState}, + StateStorage: &local.FileSystemState{Path: local.KusionStateFileFile}, Order: &opsmodels.ChangeOrder{}, }, args: args{ diff --git a/pkg/engine/states/local/backend.go b/pkg/engine/states/local/backend.go index 7a5227a6..90da3b20 100644 --- a/pkg/engine/states/local/backend.go +++ b/pkg/engine/states/local/backend.go @@ -1,7 +1,10 @@ package local import ( + "errors" + "github.com/zclconf/go-cty/cty" + "kusionstack.io/kusion/pkg/engine/states" ) @@ -26,10 +29,10 @@ func (f *LocalBackend) ConfigSchema() cty.Type { func (f *LocalBackend) Configure(obj cty.Value) error { var path cty.Value - if path = obj.GetAttr("path"); !path.IsNull() && path.AsString() != "" { - f.Path = path.AsString() - } else { - f.Path = KusionState + // path should be configured by kusion, not by workspace or cli flags. + if path = obj.GetAttr("path"); path.IsNull() || path.AsString() == "" { + return errors.New("path must be configure in backend config") } + f.Path = path.AsString() return nil } diff --git a/pkg/engine/states/local/filesystem_state.go b/pkg/engine/states/local/filesystem_state.go index 55451c55..2740a5dd 100644 --- a/pkg/engine/states/local/filesystem_state.go +++ b/pkg/engine/states/local/filesystem_state.go @@ -1,9 +1,10 @@ package local import ( - "encoding/json" "io/fs" "os" + "path" + "path/filepath" "time" "gopkg.in/yaml.v3" @@ -23,35 +24,45 @@ func NewFileSystemState() states.StateStorage { return &FileSystemState{} } -const KusionState = "kusion_state.json" +const ( + deprecatedKusionStateFile = "kusion_state.json" // deprecated default kusion state file + KusionStateFileFile = "kusion_state.yaml" +) func (f *FileSystemState) GetLatestState(query *states.StateQuery) (*states.State, error) { + filePath := f.Path + // if the file of specified path does not exist, use deprecated kusion state file. + if deprecatedPath := f.usingDeprecatedKusionStateFilePath(); deprecatedPath != "" { + filePath = deprecatedPath + log.Infof("use deprecated kusion state file %s", filePath) + } + // create a new state file if no file exists - file, err := os.OpenFile(f.Path, os.O_RDWR|os.O_CREATE, fs.ModePerm) + file, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE, fs.ModePerm) if err != nil { return nil, err } defer file.Close() - jsonFile, err := os.ReadFile(f.Path) + yamlFile, err := os.ReadFile(filePath) if err != nil { return nil, err } - if len(jsonFile) != 0 { + if len(yamlFile) != 0 { state := &states.State{} // JSON is a subset of YAML. // We are using yaml.Unmarshal here (instead of json.Unmarshal) because the // Go JSON library doesn't try to pick the right number type (int, float, // etc.) when unmarshalling to interface{}, it just picks float64 universally. // go-yaml does the right thing. - err = yaml.Unmarshal(jsonFile, state) + err = yaml.Unmarshal(yamlFile, state) if err != nil { return nil, err } return state, nil } else { - log.Infof("file %s is empty. Skip unmarshal json", f.Path) + log.Infof("file %s is empty. Skip unmarshal", filePath) return nil, nil } } @@ -72,11 +83,11 @@ func (f *FileSystemState) Apply(state *states.State) error { } state.ModifiedTime = now - jsonByte, err := json.MarshalIndent(state, "", " ") + yamlByte, err := yaml.Marshal(state) if err != nil { return err } - return os.WriteFile(f.Path, jsonByte, fs.ModePerm) + return os.WriteFile(f.Path, yamlByte, fs.ModePerm) } func (f *FileSystemState) Delete(id string) error { @@ -85,5 +96,31 @@ func (f *FileSystemState) Delete(id string) error { if err != nil { return err } + // if deprecated kusion state file exists, also delete + if f.deprecatedKusionStateFileExist() { + deprecatedPath := path.Join(filepath.Dir(f.Path), deprecatedKusionStateFile) + if err = os.Remove(deprecatedPath); err != nil { + return err + } + log.Infof("delete deprecated state file %s", deprecatedPath) + } return nil } + +func (f *FileSystemState) usingDeprecatedKusionStateFilePath() string { + _, err := os.Stat(f.Path) + if os.IsNotExist(err) { + dir := filepath.Dir(f.Path) + deprecatedPath := path.Join(dir, deprecatedKusionStateFile) + if _, err = os.Stat(deprecatedPath); err == nil { + return deprecatedPath + } + } + return "" +} + +func (f *FileSystemState) deprecatedKusionStateFileExist() bool { + dir := filepath.Dir(f.Path) + _, err := os.Stat(path.Join(dir, deprecatedKusionStateFile)) + return err == nil +} diff --git a/pkg/engine/states/local/filesystem_state_test.go b/pkg/engine/states/local/filesystem_state_test.go index 90693023..48f20ae7 100644 --- a/pkg/engine/states/local/filesystem_state_test.go +++ b/pkg/engine/states/local/filesystem_state_test.go @@ -3,6 +3,7 @@ package local import ( "io/fs" "os" + "path" "path/filepath" "reflect" "testing" @@ -13,11 +14,14 @@ import ( "kusionstack.io/kusion/pkg/engine/states" ) -var stateFile string +var stateFile, stateFileForDelete, deprecatedStateFile, deprecatedStateFileForDelete string func TestMain(m *testing.M) { currentDir, _ := os.Getwd() - stateFile = filepath.Join(currentDir, "testdata", "kusion_state.json") + stateFile = filepath.Join(currentDir, "testdata/test_stack", KusionStateFileFile) + stateFileForDelete = filepath.Join(currentDir, "testdata/test_stack_for_delete", KusionStateFileFile) + deprecatedStateFile = filepath.Join(currentDir, "testdata/deprecated_test_stack", KusionStateFileFile) + deprecatedStateFileForDelete = filepath.Join(currentDir, "testdata/deprecated_test_stack_for_delete", KusionStateFileFile) m.Run() os.Exit(0) @@ -67,6 +71,17 @@ func TestFileSystemState_GetLatestState(t *testing.T) { want: nil, wantErr: false, }, + { + name: "use deprecated kusion_state.json", + fields: fields{ + Path: deprecatedStateFile, + }, + args: args{ + query: &states.StateQuery{}, + }, + want: &states.State{ID: 1}, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -93,7 +108,7 @@ func FileSystemStateSetUp(t *testing.T) *FileSystemState { return nil }).Build() - return &FileSystemState{Path: "kusion_state_filesystem.json"} + return &FileSystemState{Path: "kusion_state_filesystem.yaml"} } func TestFileSystemState(t *testing.T) { @@ -103,6 +118,46 @@ func TestFileSystemState(t *testing.T) { err := fileSystemState.Apply(state) assert.NoError(t, err) - err = fileSystemState.Delete("kusion_state_filesystem.json") + err = fileSystemState.Delete("kusion_state_filesystem.yaml") assert.NoError(t, err) } + +func TestFileSystem_Delete(t *testing.T) { + testcases := []struct { + name string + success bool + stateFilePath string + useDeprecatedStateFile bool + }{ + { + name: "delete default state file", + success: true, + stateFilePath: stateFileForDelete, + useDeprecatedStateFile: false, + }, + { + name: "delete both default and deprecated state file", + success: true, + stateFilePath: deprecatedStateFileForDelete, + useDeprecatedStateFile: true, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + fileSystemState := &FileSystemState{Path: tc.stateFilePath} + err := fileSystemState.Delete("") + assert.NoError(t, err) + assert.NoFileExists(t, tc.stateFilePath) + file, _ := os.Create(tc.stateFilePath) + _ = file.Close() + if tc.useDeprecatedStateFile { + dir := filepath.Dir(tc.stateFilePath) + deprecatedStateFilePath := path.Join(dir, deprecatedKusionStateFile) + assert.NoFileExists(t, deprecatedStateFilePath) + deprecatedFile, _ := os.Create(deprecatedStateFilePath) + _ = deprecatedFile.Close() + } + }) + } +} diff --git a/pkg/engine/states/local/testdata/deprecated_test_stack/kusion_state.json b/pkg/engine/states/local/testdata/deprecated_test_stack/kusion_state.json new file mode 100644 index 00000000..d760fd16 --- /dev/null +++ b/pkg/engine/states/local/testdata/deprecated_test_stack/kusion_state.json @@ -0,0 +1 @@ +{"id":1,"project":"","stack":"","version":0,"kusionVersion":"","serial":0} \ No newline at end of file diff --git a/pkg/engine/states/local/testdata/kusion_state.json b/pkg/engine/states/local/testdata/deprecated_test_stack_for_delete/kusion_state.json similarity index 100% rename from pkg/engine/states/local/testdata/kusion_state.json rename to pkg/engine/states/local/testdata/deprecated_test_stack_for_delete/kusion_state.json diff --git a/pkg/engine/states/local/testdata/deprecated_test_stack_for_delete/kusion_state.yaml b/pkg/engine/states/local/testdata/deprecated_test_stack_for_delete/kusion_state.yaml new file mode 100644 index 00000000..e69de29b diff --git a/pkg/engine/states/local/testdata/test_stack/kusion_state.yaml b/pkg/engine/states/local/testdata/test_stack/kusion_state.yaml new file mode 100755 index 00000000..e69de29b diff --git a/pkg/engine/states/local/testdata/test_stack_for_delete/kusion_state.yaml b/pkg/engine/states/local/testdata/test_stack_for_delete/kusion_state.yaml new file mode 100644 index 00000000..e69de29b diff --git a/pkg/engine/states/remote/db/db_backend.go b/pkg/engine/states/remote/db/db_backend.go deleted file mode 100644 index dc0bfd81..00000000 --- a/pkg/engine/states/remote/db/db_backend.go +++ /dev/null @@ -1,70 +0,0 @@ -package db - -import ( - "errors" - "net/url" - - "github.com/didi/gendry/manager" - "github.com/zclconf/go-cty/cty" - "kusionstack.io/kusion/pkg/engine/states" -) - -type DBBackend struct { - DBState -} - -func NewDBBackend() states.Backend { - return &DBBackend{} -} - -// ConfigSchema returns a description of the expected configuration -// structure for the receiving backend. -func (b *DBBackend) ConfigSchema() cty.Type { - config := map[string]cty.Type{ - "dbName": cty.String, - "dbUser": cty.String, - "dbPassword": cty.String, - "dbHost": cty.String, - "dbPort": cty.Number, - } - return cty.Object(config) -} - -// Configure uses the provided configuration to set configuration fields -// within the DBState backend. -func (b *DBBackend) Configure(obj cty.Value) error { - var dbName, dbUser, dbPassword, dbHost, dbPort cty.Value - if dbName = obj.GetAttr("dbName"); dbName.IsNull() { - return errors.New("dbName must be configure in backend config") - } - if dbUser = obj.GetAttr("dbUser"); dbUser.IsNull() { - return errors.New("dbUser must be configure in backend config") - } - if dbPassword = obj.GetAttr("dbPassword"); dbPassword.IsNull() { - return errors.New("dbPassword must be configure in backend config") - } - if dbHost = obj.GetAttr("dbHost"); dbHost.IsNull() { - return errors.New("dbHost must be configure in backend config") - } - if dbPort = obj.GetAttr("dbPort"); dbPort.IsNull() { - return errors.New("dbPort must be configure in backend config") - } - port, _ := dbPort.AsBigFloat().Int64() - - db, err := manager.New(dbName.AsString(), dbUser.AsString(), dbPassword.AsString(), dbHost.AsString()).Set( - manager.SetCharset("utf8"), - manager.SetParseTime(true), - manager.SetInterpolateParams(true), - manager.SetLoc(url.QueryEscape("Asia/Shanghai"))).Port(int(port)).Open(true) - if err != nil { - return err - } - b.DB = db - - return nil -} - -// StateStorage return a StateStorage to manage State stored in db -func (b *DBBackend) StateStorage() states.StateStorage { - return &DBState{b.DB} -} diff --git a/pkg/engine/states/remote/mysql/mysql_backend.go b/pkg/engine/states/remote/mysql/mysql_backend.go new file mode 100644 index 00000000..0494af84 --- /dev/null +++ b/pkg/engine/states/remote/mysql/mysql_backend.go @@ -0,0 +1,72 @@ +package mysql + +import ( + "errors" + "net/url" + + "github.com/didi/gendry/manager" + "github.com/zclconf/go-cty/cty" + + "kusionstack.io/kusion/pkg/engine/states" +) + +type MysqlBackend struct { + MysqlState +} + +func NewMysqlBackend() states.Backend { + return &MysqlBackend{} +} + +// ConfigSchema returns a description of the expected configuration +// structure for the receiving backend. +func (b *MysqlBackend) ConfigSchema() cty.Type { + config := map[string]cty.Type{ + "dbName": cty.String, + "user": cty.String, + "password": cty.String, + "host": cty.String, + "port": cty.Number, + } + return cty.Object(config) +} + +// Configure uses the provided configuration to set configuration fields +// within the MysqlState backend. +func (b *MysqlBackend) Configure(obj cty.Value) error { + var dbName, dbUser, dbPassword, dbHost, dbPort cty.Value + if dbName = obj.GetAttr("dbName"); dbName.IsNull() { + return errors.New("dbName must be configure in backend config") + } + if dbUser = obj.GetAttr("user"); dbUser.IsNull() { + return errors.New("user must be configure in backend config") + } + if dbHost = obj.GetAttr("host"); dbHost.IsNull() { + return errors.New("host must be configure in backend config") + } + if dbPort = obj.GetAttr("port"); dbPort.IsNull() { + return errors.New("port must be configure in backend config") + } + + port, _ := dbPort.AsBigFloat().Int64() + var password string + if dbPassword = obj.GetAttr("password"); !dbPassword.IsNull() { + password = dbPassword.AsString() + } + db, err := manager.New(dbName.AsString(), dbUser.AsString(), password, dbHost.AsString()).Set( + manager.SetCharset("utf8"), + manager.SetParseTime(true), + manager.SetInterpolateParams(true), + manager.SetLoc(url.QueryEscape("Asia/Shanghai"))).Port(int(port)).Open(true) + if err != nil { + return err + } + b.DB = db + + return nil +} + +// StateStorage return a StateStorage to manage State stored in db +func (b *MysqlBackend) StateStorage() states.StateStorage { + return &MysqlState{b.DB} +} diff --git a/pkg/engine/states/remote/db/db_backend_test.go b/pkg/engine/states/remote/mysql/mysql_backend_test.go similarity index 69% rename from pkg/engine/states/remote/db/db_backend_test.go rename to pkg/engine/states/remote/mysql/mysql_backend_test.go index 9155202d..966d74bb 100644 --- a/pkg/engine/states/remote/db/db_backend_test.go +++ b/pkg/engine/states/remote/mysql/mysql_backend_test.go @@ -1,4 +1,4 @@ -package db +package mysql import ( "database/sql" @@ -19,19 +19,19 @@ func TestDBBackend_ConfigSchema(t *testing.T) { { name: "t1", want: cty.Object(map[string]cty.Type{ - "dbName": cty.String, - "dbUser": cty.String, - "dbPassword": cty.String, - "dbHost": cty.String, - "dbPort": cty.Number, + "dbName": cty.String, + "user": cty.String, + "password": cty.String, + "host": cty.String, + "port": cty.Number, }), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - s := NewDBBackend() + s := NewMysqlBackend() if got := s.ConfigSchema(); !reflect.DeepEqual(got, tt.want) { - t.Errorf("DBBackend.ConfigSchema() = %v, want %v", got, tt.want) + t.Errorf("MysqlBackend.ConfigSchema() = %v, want %v", got, tt.want) } }) } @@ -50,11 +50,11 @@ func TestDBBackend_Configure(t *testing.T) { name: "t1", args: args{ config: map[string]interface{}{ - "dbName": "kusion-db", - "dbUser": "kusion", - "dbPassword": "kusion", - "dbHost": "kusion-host", - "dbPort": 3306, + "dbName": "kusion-db", + "user": "kusion", + "password": "kusion", + "host": "kusion-host", + "port": 3306, }, }, wantErr: false, @@ -62,11 +62,11 @@ func TestDBBackend_Configure(t *testing.T) { } for _, tt := range tests { mockey.PatchConvey(tt.name, t, func() { - s := NewDBBackend() + s := NewMysqlBackend() mockDBOpen() obj, _ := gocty.ToCtyValue(tt.args.config, s.ConfigSchema()) if err := s.Configure(obj); (err != nil) != tt.wantErr { - t.Errorf("DBBackend.Configure() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("MysqlBackend.Configure() error = %v, wantErr %v", err, tt.wantErr) } }) } diff --git a/pkg/engine/states/remote/db/db_state.go b/pkg/engine/states/remote/mysql/mysql_state.go similarity index 89% rename from pkg/engine/states/remote/db/db_state.go rename to pkg/engine/states/remote/mysql/mysql_state.go index 95cdabae..bed0c01f 100644 --- a/pkg/engine/states/remote/db/db_state.go +++ b/pkg/engine/states/remote/mysql/mysql_state.go @@ -1,4 +1,4 @@ -package db +package mysql import ( "database/sql" @@ -20,19 +20,19 @@ import ( jsonutil "kusionstack.io/kusion/pkg/util/json" ) -var _ states.StateStorage = &DBState{} +var _ states.StateStorage = &MysqlState{} func NewDBState() states.StateStorage { - result := &DBState{} + result := &MysqlState{} return result } -type DBState struct { +type MysqlState struct { DB *sql.DB } // Apply save state in DB by add-only strategy. -func (s *DBState) Apply(state *states.State) error { +func (s *MysqlState) Apply(state *states.State) error { m := make(map[string]interface{}) sort.Stable(state.Resources) marshal, err := json.Marshal(state) @@ -54,11 +54,11 @@ func (s *DBState) Apply(state *states.State) error { return err } -func (s *DBState) Delete(id string) error { +func (s *MysqlState) Delete(id string) error { panic("implement me") } -func (s *DBState) GetLatestState(q *states.StateQuery) (*states.State, error) { +func (s *MysqlState) GetLatestState(q *states.StateQuery) (*states.State, error) { where := make(map[string]interface{}) if len(q.Project) == 0 { diff --git a/pkg/engine/states/remote/db/db_state_test.go b/pkg/engine/states/remote/mysql/mysql_state_test.go similarity index 95% rename from pkg/engine/states/remote/db/db_state_test.go rename to pkg/engine/states/remote/mysql/mysql_state_test.go index a5a0a055..a6d1984c 100644 --- a/pkg/engine/states/remote/db/db_state_test.go +++ b/pkg/engine/states/remote/mysql/mysql_state_test.go @@ -1,4 +1,4 @@ -package db +package mysql import ( "database/sql" @@ -21,7 +21,7 @@ func TestNewDBState(t *testing.T) { }{ { name: "t1", - want: &DBState{}, + want: &MysqlState{}, }, } for _, tt := range tests { @@ -33,7 +33,7 @@ func TestNewDBState(t *testing.T) { } } -func DBStateSetUp(t *testing.T) *DBState { +func DBStateSetUp(t *testing.T) *MysqlState { mockey.Mock((*manager.Option).Open).To(func(o *manager.Option, ping bool) (*sql.DB, error) { return &sql.DB{}, nil }).Build() @@ -48,7 +48,7 @@ func DBStateSetUp(t *testing.T) *DBState { return 1, nil }).Build() - return &DBState{DB: &sql.DB{}} + return &MysqlState{DB: &sql.DB{}} } func TestDBState(t *testing.T) { diff --git a/pkg/engine/states/remote/oss/oss_state.go b/pkg/engine/states/remote/oss/oss_state.go index d3c7a5f0..a06ffdd4 100644 --- a/pkg/engine/states/remote/oss/oss_state.go +++ b/pkg/engine/states/remote/oss/oss_state.go @@ -10,11 +10,15 @@ import ( "gopkg.in/yaml.v3" "kusionstack.io/kusion/pkg/engine/states" + "kusionstack.io/kusion/pkg/log" ) var ErrOSSNoExist = errors.New("oss: key not exist") -const OSSStateName = "kusion_state.json" +const ( + deprecatedKusionStateFile = "kusion_state.json" + KusionStateFile = "kusion_state.yaml" +) var _ states.StateStorage = &OssState{} @@ -48,9 +52,9 @@ func (s *OssState) Apply(state *states.State) error { var prefix string if state.Tenant != "" { - prefix = state.Tenant + "/" + state.Project + "/" + state.Stack + "/" + OSSStateName + prefix = state.Tenant + "/" + state.Project + "/" + state.Stack + "/" + KusionStateFile } else { - prefix = state.Project + "/" + state.Stack + "/" + OSSStateName + prefix = state.Project + "/" + state.Stack + "/" + KusionStateFile } err = s.bucket.PutObject(prefix, bytes.NewReader(jsonByte)) @@ -67,9 +71,9 @@ func (s *OssState) Delete(id string) error { func (s *OssState) GetLatestState(query *states.StateQuery) (*states.State, error) { var prefix string if query.Tenant != "" { - prefix = query.Tenant + "/" + query.Project + "/" + query.Stack + "/" + OSSStateName + prefix = query.Tenant + "/" + query.Project + "/" + query.Stack + "/" + KusionStateFile } else { - prefix = query.Project + "/" + query.Stack + "/" + OSSStateName + prefix = query.Project + "/" + query.Stack + "/" + KusionStateFile } objects, err := s.bucket.ListObjects(oss.Delimiter("/"), oss.Prefix(prefix)) @@ -78,7 +82,16 @@ func (s *OssState) GetLatestState(query *states.StateQuery) (*states.State, erro } if len(objects.Objects) == 0 { - return nil, nil + var deprecatedPrefix string + deprecatedPrefix, err = s.usingDeprecatedStateFilePrefix(query) + if err != nil { + return nil, err + } + if deprecatedPrefix == "" { + return nil, nil + } + prefix = deprecatedPrefix + log.Infof("using deprecated oss kusion state file %s", prefix) } body, err := s.bucket.GetObject(prefix) @@ -99,3 +112,21 @@ func (s *OssState) GetLatestState(query *states.StateQuery) (*states.State, erro } return state, nil } + +func (s *OssState) usingDeprecatedStateFilePrefix(query *states.StateQuery) (string, error) { + var prefix string + if query.Tenant != "" { + prefix = query.Tenant + "/" + query.Project + "/" + query.Stack + "/" + deprecatedKusionStateFile + } else { + prefix = query.Project + "/" + query.Stack + "/" + deprecatedKusionStateFile + } + + objects, err := s.bucket.ListObjects(oss.Delimiter("/"), oss.Prefix(prefix)) + if err != nil { + return "", err + } + if len(objects.Objects) == 0 { + return "", nil + } + return prefix, nil +} diff --git a/pkg/engine/states/remote/oss/oss_state_test.go b/pkg/engine/states/remote/oss/oss_state_test.go index 395dbd40..118b6f0b 100644 --- a/pkg/engine/states/remote/oss/oss_state_test.go +++ b/pkg/engine/states/remote/oss/oss_state_test.go @@ -57,3 +57,43 @@ func TestOssState(t *testing.T) { ossState.Delete("test") }) } + +func TestUsingDeprecatedStateFilePrefix(t *testing.T) { + testcases := []struct { + name string + success bool + query *states.StateQuery + expectedPrefix string + }{ + { + name: "prefix with tenant", + success: true, + query: &states.StateQuery{ + Tenant: "test_tenant", + Project: "test_project", + Stack: "test_stack", + }, + expectedPrefix: "test_tenant/test_project/test_stack/kusion_state.json", + }, + { + name: "prefix without tenant", + success: true, + query: &states.StateQuery{ + Project: "test_project", + Stack: "test_stack", + }, + expectedPrefix: "test_project/test_stack/kusion_state.json", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + mockey.PatchConvey("mock oss state", t, func() { + ossState := SetUp(t) + prefix, err := ossState.usingDeprecatedStateFilePrefix(tc.query) + assert.NoError(t, err) + assert.Equal(t, tc.expectedPrefix, prefix) + }) + }) + } +} diff --git a/pkg/engine/states/remote/s3/s3_backend.go b/pkg/engine/states/remote/s3/s3_backend.go index 88e961da..a3d12363 100644 --- a/pkg/engine/states/remote/s3/s3_backend.go +++ b/pkg/engine/states/remote/s3/s3_backend.go @@ -7,6 +7,7 @@ import ( "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/session" "github.com/zclconf/go-cty/cty" + "kusionstack.io/kusion/pkg/engine/states" ) @@ -35,9 +36,6 @@ func (b *S3Backend) ConfigSchema() cty.Type { // within the S3State backend. func (b *S3Backend) Configure(obj cty.Value) error { var endpoint, bucket, accessKeyID, accessKeySecret, region cty.Value - if endpoint = obj.GetAttr("endpoint"); endpoint.IsNull() { - return errors.New("s3 endpoint must be configure in backend config") - } if bucket = obj.GetAttr("bucket"); bucket.IsNull() { return errors.New("s3 bucket must be configure in backend config") } @@ -50,13 +48,16 @@ func (b *S3Backend) Configure(obj cty.Value) error { if region = obj.GetAttr("region"); region.IsNull() { return errors.New("s3 region must be configure in backend config") } - sess, err := session.NewSession(&aws.Config{ + config := &aws.Config{ Credentials: credentials.NewStaticCredentials(accessKeyID.AsString(), accessKeySecret.AsString(), ""), - Endpoint: aws.String(endpoint.AsString()), Region: aws.String(region.AsString()), DisableSSL: aws.Bool(true), S3ForcePathStyle: aws.Bool(true), - }) + } + if endpoint = obj.GetAttr("endpoint"); !endpoint.IsNull() { + config.Endpoint = aws.String(endpoint.AsString()) + } + sess, err := session.NewSession(config) if err != nil { return err } diff --git a/pkg/engine/states/remote/s3/s3_state.go b/pkg/engine/states/remote/s3/s3_state.go index 29995020..c647ced6 100644 --- a/pkg/engine/states/remote/s3/s3_state.go +++ b/pkg/engine/states/remote/s3/s3_state.go @@ -12,11 +12,15 @@ import ( "github.com/aws/aws-sdk-go/service/s3" "kusionstack.io/kusion/pkg/engine/states" + "kusionstack.io/kusion/pkg/log" ) var ErrS3NoExist = errors.New("s3: key not exist") -const S3StateName = "kusion_state.json" +const ( + deprecatedKusionStateFile = "kusion_state.json" + KusionStateFile = "kusion_state.yaml" +) var _ states.StateStorage = &S3State{} @@ -25,14 +29,17 @@ type S3State struct { bucketName string } -func NewS3State(endPoint, accessKeyID, accessKeySecret, bucketName string, region string) (*S3State, error) { - sess, err := session.NewSession(&aws.Config{ +func NewS3State(endpoint, accessKeyID, accessKeySecret, bucketName string, region string) (*S3State, error) { + config := &aws.Config{ Credentials: credentials.NewStaticCredentials(accessKeyID, accessKeySecret, ""), - Endpoint: aws.String(endPoint), Region: aws.String(region), DisableSSL: aws.Bool(true), S3ForcePathStyle: aws.Bool(false), - }) + } + if endpoint != "" { + config.Endpoint = aws.String(endpoint) + } + sess, err := session.NewSession(config) if err != nil { return nil, err } @@ -51,9 +58,9 @@ func (s *S3State) Apply(state *states.State) error { var prefix string if state.Tenant != "" { - prefix = state.Tenant + "/" + state.Project + "/" + state.Stack + "/" + S3StateName + prefix = state.Tenant + "/" + state.Project + "/" + state.Stack + "/" + KusionStateFile } else { - prefix = state.Project + "/" + state.Stack + "/" + S3StateName + prefix = state.Project + "/" + state.Stack + "/" + KusionStateFile } s3Client := s3.New(s.sess) @@ -76,9 +83,9 @@ func (s *S3State) Delete(id string) error { func (s *S3State) GetLatestState(query *states.StateQuery) (*states.State, error) { var prefix string if query.Tenant != "" { - prefix = query.Tenant + "/" + query.Project + "/" + query.Stack + "/" + S3StateName + prefix = query.Tenant + "/" + query.Project + "/" + query.Stack + "/" + KusionStateFile } else { - prefix = query.Project + "/" + query.Stack + "/" + S3StateName + prefix = query.Project + "/" + query.Stack + "/" + KusionStateFile } s3Client := s3.New(s.sess) @@ -94,7 +101,16 @@ func (s *S3State) GetLatestState(query *states.StateQuery) (*states.State, error } if len(objects.Contents) == 0 { - return nil, nil + var deprecatedPrefix string + deprecatedPrefix, err = s.usingDeprecatedStateFilePrefix(query) + if err != nil { + return nil, err + } + if deprecatedPrefix == "" { + return nil, nil + } + prefix = deprecatedPrefix + log.Infof("using deprecated s3 kusion state file %s", prefix) } out, err := s3Client.GetObject(&s3.GetObjectInput{ @@ -117,3 +133,28 @@ func (s *S3State) GetLatestState(query *states.StateQuery) (*states.State, error } return state, nil } + +func (s *S3State) usingDeprecatedStateFilePrefix(query *states.StateQuery) (string, error) { + var prefix string + if query.Tenant != "" { + prefix = query.Tenant + "/" + query.Project + "/" + query.Stack + "/" + deprecatedKusionStateFile + } else { + prefix = query.Project + "/" + query.Stack + "/" + deprecatedKusionStateFile + } + s3Client := s3.New(s.sess) + + params := &s3.ListObjectsInput{ + Bucket: aws.String(s.bucketName), + Delimiter: aws.String("/"), + Prefix: aws.String(prefix), + } + + objects, err := s3Client.ListObjects(params) + if err != nil { + return "", err + } + if len(objects.Contents) == 0 { + return "", nil + } + return prefix, nil +} diff --git a/pkg/engine/states/remote/s3/s3_state_test.go b/pkg/engine/states/remote/s3/s3_state_test.go index 62b350ba..5a495bc7 100644 --- a/pkg/engine/states/remote/s3/s3_state_test.go +++ b/pkg/engine/states/remote/s3/s3_state_test.go @@ -64,3 +64,43 @@ func TestS3State(t *testing.T) { s3State.Delete("test") }) } + +func TestUsingDeprecatedStateFilePrefix(t *testing.T) { + testcases := []struct { + name string + success bool + query *states.StateQuery + expectedPrefix string + }{ + { + name: "prefix with tenant", + success: true, + query: &states.StateQuery{ + Tenant: "test_tenant", + Project: "test_project", + Stack: "test_stack", + }, + expectedPrefix: "test_tenant/test_project/test_stack/kusion_state.json", + }, + { + name: "prefix without tenant", + success: true, + query: &states.StateQuery{ + Project: "test_project", + Stack: "test_stack", + }, + expectedPrefix: "test_project/test_stack/kusion_state.json", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + mockey.PatchConvey("mock oss state", t, func() { + s3State := S3StateSetUp(t) + prefix, err := s3State.usingDeprecatedStateFilePrefix(tc.query) + assert.NoError(t, err) + assert.Equal(t, tc.expectedPrefix, prefix) + }) + }) + } +}