diff --git a/internal/typesystem/emulator/emulator.go b/internal/typesystem/emulator/emulator.go index 39938b2f..a3587cae 100644 --- a/internal/typesystem/emulator/emulator.go +++ b/internal/typesystem/emulator/emulator.go @@ -16,6 +16,7 @@ package emulator import ( "fmt" "math/rand" + "slices" "strings" "time" @@ -24,7 +25,11 @@ import ( "k8s.io/kube-openapi/pkg/validation/spec" ) -// TODO(a-hilaly): generate fields based on the schema constraints(min, max, pattern, etc...) +var ( + // kubernetesTopLevelFields are top-level fields that are common across all + // Kubernetes resources. We don't want to generate these fields. + kubernetesTopLevelFields = []string{"apiVersion", "kind", "metadata"} +) // Emulator is used to generate dummy CRs based on an OpenAPI schema. type Emulator struct { @@ -48,46 +53,24 @@ func (e *Emulator) GenerateDummyCR(gvk schema.GroupVersionKind, schema *spec.Sch Object: make(map[string]interface{}), } - // Generate the entire object based on the schema - object, err := e.generateObject(schema) - if err != nil { - return nil, fmt.Errorf("error generating CR: %w", err) - } - - // Merge the generated object with the existing CR object - for k, v := range object { - cr.Object[k] = v - } - - // Set the GVK after generating the object... - cr.SetAPIVersion(gvk.GroupVersion().String()) - cr.SetKind(gvk.Kind) - cr.SetName(fmt.Sprintf("%s-sample", strings.ToLower(gvk.Kind))) - cr.SetNamespace("default") - - return cr, nil -} - -// generateObject generates an object (Struct) based on the provided schema. -func (e *Emulator) generateObject(schema *spec.Schema) (map[string]interface{}, error) { - if schema == nil { - return nil, fmt.Errorf("schema is nil") - } - - result := make(map[string]interface{}) + // Only generate fields from the schema for propertyName, propertySchema := range schema.Properties { - // Skip metadata as it's already set - if propertyName == "metadata" { + // Skip Kubernetes-specific top-level fields + if slices.Contains(kubernetesTopLevelFields, propertyName) { continue } value, err := e.generateValue(&propertySchema) if err != nil { - return nil, fmt.Errorf("error generating value for %s: %w", propertyName, err) + return nil, fmt.Errorf("error generating field %s: %w", propertyName, err) } - result[propertyName] = value + cr.Object[propertyName] = value } - return result, nil + cr.SetAPIVersion(gvk.GroupVersion().String()) + cr.SetKind(gvk.Kind) + cr.SetName(fmt.Sprintf("%s-sample", strings.ToLower(gvk.Kind))) + cr.SetNamespace("default") + return cr, nil } // generateValue generates a value based on the provided schema. @@ -109,7 +92,6 @@ func (e *Emulator) generateValue(schema *spec.Schema) (interface{}, error) { return nil, fmt.Errorf("schema type is empty and has no properties") } - // Handle 0 or more than type if len(schema.Type) != 1 { return nil, fmt.Errorf("schema type is not a single type: %v", schema.Type) } @@ -133,6 +115,24 @@ func (e *Emulator) generateValue(schema *spec.Schema) (interface{}, error) { } } +// generateObject generates an object based on the provided schema. +func (e *Emulator) generateObject(schema *spec.Schema) (map[string]interface{}, error) { + if schema == nil { + return nil, fmt.Errorf("schema is nil") + } + + result := make(map[string]interface{}) + for propertyName, propertySchema := range schema.Properties { + value, err := e.generateValue(&propertySchema) + if err != nil { + return nil, fmt.Errorf("error generating field %s: %w", propertyName, err) + } + result[propertyName] = value + } + + return result, nil +} + // generateString generates a string based on the provided schema. func (e *Emulator) generateString(schema *spec.Schema) string { if len(schema.Enum) > 0 { @@ -141,24 +141,61 @@ func (e *Emulator) generateString(schema *spec.Schema) string { return fmt.Sprintf("dummy-string-%d", e.rand.Intn(1000)) } -func (e *Emulator) generateInteger(_ *spec.Schema) int64 { - return e.rand.Int63n(100) +func (e *Emulator) generateInteger(schema *spec.Schema) int64 { + // Default to 0-10000 range + min := int64(0) + max := int64(10000) + + if schema.Minimum != nil { + min = int64(*schema.Minimum) + } + if schema.Maximum != nil { + max = int64(*schema.Maximum) + } + + if min == max { + return min + } + + return min + e.rand.Int63n(max-min) } -func (e *Emulator) generateNumber(_ *spec.Schema) float64 { - return e.rand.Float64() * 100 +func (e *Emulator) generateNumber(schema *spec.Schema) float64 { + min := 0.0 + max := 100.0 + + if schema.Minimum != nil { + min = *schema.Minimum + } + if schema.Maximum != nil { + max = *schema.Maximum + } + + return min + e.rand.Float64()*(max-min) } // generateArray generates an array based on the provided schema. -// TODO(a-hilaly): respect the minItems and maxItems constraints. func (e *Emulator) generateArray(schema *spec.Schema) ([]interface{}, error) { if schema.Items == nil || schema.Items.Schema == nil { return nil, fmt.Errorf("array items schema is nil") } - numItems := 1 + e.rand.Intn(3) // Generate 1 to 3 items - result := make([]interface{}, numItems) + minItems := 1 + maxItems := 3 + + if schema.MinItems != nil { + minItems = int(*schema.MinItems) + } + if schema.MaxItems != nil { + maxItems = int(*schema.MaxItems) + } + numItems := minItems + if maxItems > minItems { + numItems += e.rand.Intn(maxItems - minItems) + } + + result := make([]interface{}, numItems) for i := 0; i < numItems; i++ { value, err := e.generateValue(schema.Items.Schema) if err != nil { diff --git a/internal/typesystem/emulator/emulator_test.go b/internal/typesystem/emulator/emulator_test.go new file mode 100644 index 00000000..48ad2bbe --- /dev/null +++ b/internal/typesystem/emulator/emulator_test.go @@ -0,0 +1,324 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file is distributed +// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language governing +// permissions and limitations under the License. + +package emulator + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/kube-openapi/pkg/validation/spec" +) + +func TestGenerateDummyCR(t *testing.T) { + tests := []struct { + name string + gvk schema.GroupVersionKind + schema *spec.Schema + validateOutput func(*testing.T, map[string]interface{}) + }{ + { + name: "simple schema with basic types", + gvk: schema.GroupVersionKind{ + Group: "symphony.k8s.aws", + Version: "v1alpha1", + Kind: "SimpleTest", + }, + schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Properties: map[string]spec.Schema{ + "spec": { + SchemaProps: spec.SchemaProps{ + Properties: map[string]spec.Schema{ + "stringField": { + SchemaProps: spec.SchemaProps{ + Type: spec.StringOrArray{"string"}, + }, + }, + "intField": { + SchemaProps: spec.SchemaProps{ + Type: spec.StringOrArray{"integer"}, + }, + }, + "boolField": { + SchemaProps: spec.SchemaProps{ + Type: spec.StringOrArray{"boolean"}, + }, + }, + }, + }, + }, + }, + }, + }, + validateOutput: func(t *testing.T, obj map[string]interface{}) { + spec, ok := obj["spec"].(map[string]interface{}) + require.True(t, ok, "spec should be an object") + + // Since those fields are generated randomly, we can only check the type + // of the fields. + assert.IsType(t, "", spec["stringField"], "stringField should be string") + assert.IsType(t, int64(0), spec["intField"], "intField should be int64") + assert.IsType(t, false, spec["boolField"], "boolField should be bool") + }, + }, + { + name: "complex schema with nested objects and arrays", + gvk: schema.GroupVersionKind{ + Group: "symphony.k8s.aws", + Version: "v1alpha1", + Kind: "ComplexTest", + }, + schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Properties: map[string]spec.Schema{ + "spec": { + SchemaProps: spec.SchemaProps{ + Properties: map[string]spec.Schema{ + "config": { + SchemaProps: spec.SchemaProps{ + Type: spec.StringOrArray{"object"}, + Properties: map[string]spec.Schema{ + "name": { + SchemaProps: spec.SchemaProps{ + Type: spec.StringOrArray{"string"}, + }, + }, + "enabled": { + SchemaProps: spec.SchemaProps{ + Type: spec.StringOrArray{"boolean"}, + }, + }, + }, + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: spec.StringOrArray{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Properties: map[string]spec.Schema{ + "id": { + SchemaProps: spec.SchemaProps{ + Type: spec.StringOrArray{"string"}, + }, + }, + "value": { + SchemaProps: spec.SchemaProps{ + Type: spec.StringOrArray{"integer"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "status": { + SchemaProps: spec.SchemaProps{ + Properties: map[string]spec.Schema{ + "phase": { + SchemaProps: spec.SchemaProps{ + Type: spec.StringOrArray{"string"}, + }, + }, + "replicas": { + SchemaProps: spec.SchemaProps{ + Type: spec.StringOrArray{"integer"}, + }, + }, + }, + }, + }, + }, + }, + }, + validateOutput: func(t *testing.T, obj map[string]interface{}) { + // Validate spec + spec, ok := obj["spec"].(map[string]interface{}) + require.True(t, ok, "spec should be an object") + + // Validate config + config, ok := spec["config"].(map[string]interface{}) + require.True(t, ok, "config should be an object") + assert.IsType(t, "", config["name"], "config.name should be string") + assert.IsType(t, false, config["enabled"], "config.enabled should be bool") + + // Validate items + items, ok := spec["items"].([]interface{}) + require.True(t, ok, "items should be an array") + require.NotEmpty(t, items, "items should not be empty") + + for _, item := range items { + itemMap, ok := item.(map[string]interface{}) + require.True(t, ok, "item should be an object") + assert.IsType(t, "", itemMap["id"], "item.id should be string") + assert.IsType(t, int64(0), itemMap["value"], "item.value should be int64") + } + + // Validate status + status, ok := obj["status"].(map[string]interface{}) + require.True(t, ok, "status should be an object") + assert.IsType(t, "", status["phase"], "status.phase should be string") + assert.IsType(t, int64(0), status["replicas"], "status.replicas should be int64") + }, + }, + { + name: "schema with enum values", + gvk: schema.GroupVersionKind{ + Group: "symphony.k8s.aws", + Version: "v1alpha1", + Kind: "EnumTest", + }, + schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Properties: map[string]spec.Schema{ + "spec": { + SchemaProps: spec.SchemaProps{ + Properties: map[string]spec.Schema{ + "mode": { + SchemaProps: spec.SchemaProps{ + Type: spec.StringOrArray{"string"}, + Enum: []interface{}{"Auto", "Manual", "Disabled"}, + }, + }, + }, + }, + }, + }, + }, + }, + validateOutput: func(t *testing.T, obj map[string]interface{}) { + spec, ok := obj["spec"].(map[string]interface{}) + require.True(t, ok, "spec should be an object") + + mode, ok := spec["mode"].(string) + require.True(t, ok, "mode should be string") + assert.Contains(t, []string{"Auto", "Manual", "Disabled"}, mode) + }, + }, + { + name: "schema with number constraints", + gvk: schema.GroupVersionKind{ + Group: "symphony.k8s.aws", + Version: "v1alpha1", + Kind: "ConstrainedTest", + }, + schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Properties: map[string]spec.Schema{ + "spec": { + SchemaProps: spec.SchemaProps{ + Properties: map[string]spec.Schema{ + "value": { + SchemaProps: spec.SchemaProps{ + Type: spec.StringOrArray{"integer"}, + Minimum: ptr(0.0), + Maximum: ptr(100.0), + }, + }, + "ratio": { + SchemaProps: spec.SchemaProps{ + Type: spec.StringOrArray{"number"}, + Minimum: ptr(0.0), + Maximum: ptr(1.0), + }, + }, + }, + }, + }, + }, + }, + }, + validateOutput: func(t *testing.T, obj map[string]interface{}) { + spec, ok := obj["spec"].(map[string]interface{}) + require.True(t, ok, "spec should be a object") + + value, ok := spec["value"].(int64) + require.True(t, ok, "value should be int64") + assert.GreaterOrEqual(t, value, int64(0)) + assert.LessOrEqual(t, value, int64(100)) + + ratio, ok := spec["ratio"].(float64) + require.True(t, ok, "ratio should be float64") + assert.GreaterOrEqual(t, ratio, 0.0) + assert.LessOrEqual(t, ratio, 1.0) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := NewEmulator() + cr, err := e.GenerateDummyCR(tt.gvk, tt.schema) + require.NoError(t, err) + require.NotNil(t, cr) + + assert.Equal(t, tt.gvk.GroupVersion().String(), cr.GetAPIVersion()) + assert.Equal(t, tt.gvk.Kind, cr.GetKind()) + assert.Equal(t, "default", cr.GetNamespace()) + assert.Equal(t, strings.ToLower(tt.gvk.Kind)+"-sample", cr.GetName()) + + tt.validateOutput(t, cr.Object) + }) + } +} + +func TestGenerateDummyCRErrors(t *testing.T) { + e := NewEmulator() + gvk := schema.GroupVersionKind{ + Group: "symphony.k8s.aws", + Version: "v1alpha1", + Kind: "ErrorTest", + } + + t.Run("nil schema", func(t *testing.T) { + _, err := e.GenerateDummyCR(gvk, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "schema is nil") + }) + + t.Run("invalid type", func(t *testing.T) { + schema := &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Properties: map[string]spec.Schema{ + "spec": { + SchemaProps: spec.SchemaProps{ + Properties: map[string]spec.Schema{ + "field": { + SchemaProps: spec.SchemaProps{ + Type: spec.StringOrArray{"invalid"}, + }, + }, + }, + }, + }, + }, + }, + } + _, err := e.GenerateDummyCR(gvk, schema) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported type") + }) +} + +func ptr[T comparable](v T) *T { + return &v +}