diff --git a/pkg/apis/api.kusion.io/v1/types.go b/pkg/apis/api.kusion.io/v1/types.go index 59c5f915..3d86c2e4 100644 --- a/pkg/apis/api.kusion.io/v1/types.go +++ b/pkg/apis/api.kusion.io/v1/types.go @@ -893,7 +893,7 @@ type Release struct { Stack string `yaml:"stack" json:"stack"` // Spec of the Release, which can be provided when creating release or generated during Release. - Spec *Spec `yaml:"spec" json:"spec"` + Spec *Spec `yaml:"spec,omitempty" json:"spec,omitempty"` // State of the Release, which will be generated and updated during Release. When a Release is created, // the State will be filled with the latest State, which indicates the current infra resources. @@ -906,7 +906,7 @@ type Release struct { CreateTime time.Time `yaml:"createTime" json:"createTime"` // ModifiedTime is the time that the Release is modified. - ModifiedTime time.Time `yaml:"modifiedTime,omitempty" json:"modifiedTime,omitempty"` + ModifiedTime time.Time `yaml:"modifiedTime" json:"modifiedTime"` } // DeprecatedState is a record of an operation's result. It is a mapping between resources in KCL and the actual infra diff --git a/pkg/engine/release/validation.go b/pkg/engine/release/validation.go new file mode 100644 index 00000000..faa556ed --- /dev/null +++ b/pkg/engine/release/validation.go @@ -0,0 +1,85 @@ +package release + +import ( + "errors" + "fmt" + + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" +) + +var ( + ErrEmptyRelease = errors.New("empty release") + ErrEmptyProject = errors.New("empty project") + ErrEmptyWorkspace = errors.New("empty workspace") + ErrEmptyRevision = errors.New("empty revision") + ErrEmptyStack = errors.New("empty stack") + ErrEmptySpec = errors.New("empty spec") + ErrEmptyState = errors.New("empty state") + ErrEmptyPhase = errors.New("empty phase") + ErrEmptyCreateTime = errors.New("empty create time") + ErrEmptyModifiedTime = errors.New("empty modified time") + ErrDuplicateResourceKey = errors.New("duplicate resource key") +) + +func ValidateRelease(r *v1.Release) error { + if r == nil { + return ErrEmptyRelease + } + if r.Project == "" { + return ErrEmptyProject + } + if r.Workspace == "" { + return ErrEmptyWorkspace + } + if r.Revision == 0 { + return ErrEmptyRevision + } + if r.Stack == "" { + return ErrEmptyStack + } + if err := ValidateState(r.State); err != nil { + return err + } + if r.Phase == "" { + return ErrEmptyPhase + } + if r.CreateTime.IsZero() { + return ErrEmptyCreateTime + } + if r.ModifiedTime.IsZero() { + return ErrEmptyModifiedTime + } + return nil +} + +func ValidateSpec(spec *v1.Spec) error { + if spec == nil { + return ErrEmptySpec + } + if err := validateResources(spec.Resources); err != nil { + return err + } + return nil +} + +func ValidateState(state *v1.State) error { + if state == nil { + return ErrEmptyState + } + if err := validateResources(state.Resources); err != nil { + return err + } + return nil +} + +func validateResources(resources v1.Resources) error { + resourceKeyMap := make(map[string]bool) + for _, resource := range resources { + key := resource.ResourceKey() + if _, ok := resourceKeyMap[key]; ok { + return fmt.Errorf("%w: %s", ErrDuplicateResourceKey, key) + } + resourceKeyMap[key] = true + } + return nil +} diff --git a/pkg/engine/release/validation_test.go b/pkg/engine/release/validation_test.go new file mode 100644 index 00000000..30fac222 --- /dev/null +++ b/pkg/engine/release/validation_test.go @@ -0,0 +1,266 @@ +package release + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" +) + +func mockResource() v1.Resource { + return 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", + }, + } +} + +func TestValidateRelease(t *testing.T) { + loc, _ := time.LoadLocation("Asia/Shanghai") + testcases := []struct { + name string + success bool + release *v1.Release + }{ + { + name: "valid release", + success: true, + release: &v1.Release{ + Project: "fake-project", + Workspace: "fake-ws", + Revision: 1, + Stack: "fake-stack", + State: &v1.State{Resources: v1.Resources{mockResource()}}, + Phase: v1.ReleasePhaseApplying, + CreateTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), + ModifiedTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), + }, + }, + { + name: "invalid release empty project", + success: false, + release: &v1.Release{ + Project: "", + Workspace: "fake-ws", + Revision: 1, + Stack: "fake-stack", + State: &v1.State{Resources: v1.Resources{mockResource()}}, + Phase: v1.ReleasePhaseApplying, + CreateTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), + ModifiedTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), + }, + }, + { + name: "invalid release empty workspace", + success: false, + release: &v1.Release{ + Project: "fake-project", + Workspace: "", + Revision: 1, + Stack: "fake-stack", + State: &v1.State{Resources: v1.Resources{mockResource()}}, + Phase: v1.ReleasePhaseApplying, + CreateTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), + ModifiedTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), + }, + }, + { + name: "invalid release empty revision", + success: false, + release: &v1.Release{ + Project: "fake-project", + Workspace: "fake-ws", + Revision: 0, + Stack: "fake-stack", + State: &v1.State{Resources: v1.Resources{mockResource()}}, + Phase: v1.ReleasePhaseApplying, + CreateTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), + ModifiedTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), + }, + }, + { + name: "invalid release empty stack", + success: false, + release: &v1.Release{ + Project: "fake-project", + Workspace: "fake-ws", + Revision: 1, + Stack: "", + State: &v1.State{Resources: v1.Resources{mockResource()}}, + Phase: v1.ReleasePhaseApplying, + CreateTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), + ModifiedTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), + }, + }, + { + name: "invalid release empty state", + success: false, + release: &v1.Release{ + Project: "fake-project", + Workspace: "fake-ws", + Revision: 1, + Stack: "fake-stack", + State: nil, + Phase: v1.ReleasePhaseApplying, + CreateTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), + ModifiedTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), + }, + }, + { + name: "invalid release empty phase", + success: false, + release: &v1.Release{ + Project: "fake-project", + Workspace: "fake-ws", + Revision: 1, + Stack: "fake-stack", + State: &v1.State{Resources: v1.Resources{mockResource()}}, + Phase: "", + CreateTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), + ModifiedTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), + }, + }, + { + name: "invalid release empty create time", + success: false, + release: &v1.Release{ + Project: "fake-project", + Workspace: "fake-ws", + Revision: 1, + Stack: "fake-stack", + State: &v1.State{Resources: v1.Resources{mockResource()}}, + Phase: v1.ReleasePhaseApplying, + ModifiedTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), + }, + }, + { + name: "invalid release empty modified time", + success: false, + release: &v1.Release{ + Project: "fake-project", + Workspace: "fake-ws", + Revision: 1, + Stack: "fake-stack", + State: &v1.State{Resources: v1.Resources{mockResource()}}, + Phase: v1.ReleasePhaseApplying, + CreateTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateRelease(tc.release) + assert.Equal(t, tc.success, err == nil) + }) + } +} + +func TestValidateState(t *testing.T) { + testcases := []struct { + name string + success bool + state *v1.State + }{ + { + name: "valid state", + success: true, + state: &v1.State{ + Resources: v1.Resources{mockResource()}, + }, + }, + { + name: "invalid state nil state", + success: false, + state: nil, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateState(tc.state) + assert.Equal(t, tc.success, err == nil) + }) + } +} + +func TestValidateSpec(t *testing.T) { + testcases := []struct { + name string + success bool + spec *v1.Spec + }{ + { + name: "valid spec", + success: true, + spec: &v1.Spec{ + Resources: v1.Resources{mockResource()}, + }, + }, + { + name: "invalid spec nil spec", + success: false, + spec: nil, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateSpec(tc.spec) + assert.Equal(t, tc.success, err == nil) + }) + } +} + +func TestValidateResources(t *testing.T) { + testcases := []struct { + name string + success bool + resources v1.Resources + }{ + { + name: "valid resources", + success: true, + resources: v1.Resources{mockResource()}, + }, + { + name: "invalid resources duplicate resource key", + success: false, + resources: v1.Resources{mockResource(), mockResource()}, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + err := validateResources(tc.resources) + assert.Equal(t, tc.success, err == nil) + }) + } +}