diff --git a/docs/generated/checks.md b/docs/generated/checks.md index 770bf3401..7addc722e 100644 --- a/docs/generated/checks.md +++ b/docs/generated/checks.md @@ -613,6 +613,21 @@ dirs: - ^/sys$ - ^/usr$ ``` +## sorted-keys + +**Enabled by default**: No + +**Description**: Check that YAML keys are sorted in alphabetical order wherever possible. + +**Remediation**: Ensure that keys in your YAML manifest are sorted in alphabetical order to improve consistency and readability. + +**Template**: [sorted-keys](templates.md#sorted-keys) + +**Parameters**: + +```yaml +recursive: true +``` ## ssh-port **Enabled by default**: Yes diff --git a/docs/generated/templates.md b/docs/generated/templates.md index 38242c5df..ce639e3aa 100644 --- a/docs/generated/templates.md +++ b/docs/generated/templates.md @@ -820,6 +820,25 @@ KubeLinter supports the following templates: type: string ``` +## Sorted Keys + +**Key**: `sorted-keys` + +**Description**: Flag YAML keys that are not sorted in alphabetical order + +**Supported Objects**: Any + + +**Parameters**: + +```yaml +- description: Recursive determines whether to check keys recursively at all nesting + levels. Default is true. + name: recursive + required: false + type: boolean +``` + ## Startup Port Exposed **Key**: `startup-port` diff --git a/e2etests/bats-tests.sh b/e2etests/bats-tests.sh index 724144317..2f614468c 100755 --- a/e2etests/bats-tests.sh +++ b/e2etests/bats-tests.sh @@ -926,6 +926,25 @@ get_value_from() { [[ "${count}" == "2" ]] } +@test "sorted-keys" { + tmp="tests/checks/sorted-keys.yaml" + cmd="${KUBE_LINTER_BIN} lint --include sorted-keys --do-not-auto-add-defaults --format json ${tmp}" + run ${cmd} + + print_info "${status}" "${output}" "${cmd}" "${tmp}" + [ "$status" -eq 1 ] + + message1=$(get_value_from "${lines[0]}" '.Reports[0].Object.K8sObject.GroupVersionKind.Kind + ": " + .Reports[0].Diagnostic.Message') + message2=$(get_value_from "${lines[0]}" '.Reports[1].Object.K8sObject.GroupVersionKind.Kind + ": " + .Reports[1].Diagnostic.Message') + message3=$(get_value_from "${lines[0]}" '.Reports[2].Object.K8sObject.GroupVersionKind.Kind + ": " + .Reports[2].Diagnostic.Message') + count=$(get_value_from "${lines[0]}" '.Reports | length') + + [[ "${message1}" == "Deployment: Keys are not sorted at spec.template.spec.containers[0]. Expected order: [image, name, ports], got: [name, image, ports]" ]] + [[ "${message2}" == "Deployment: Keys are not sorted at root. Expected order: [apiVersion, kind, metadata, spec], got: [apiVersion, metadata, spec, kind]" ]] + [[ "${message3}" == "Deployment: Keys are not sorted at spec.template. Expected order: [metadata, spec], got: [spec, metadata]" ]] + [[ "${count}" == "27" ]] +} + @test "ssh-port" { tmp="tests/checks/ssh-port.yml" cmd="${KUBE_LINTER_BIN} lint --include ssh-port --do-not-auto-add-defaults --format json ${tmp}" diff --git a/go.mod b/go.mod index 4d10fb03b..d932aa40a 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 helm.sh/helm/v3 v3.19.0 k8s.io/api v0.34.1 k8s.io/apimachinery v0.34.1 @@ -122,7 +123,6 @@ require ( google.golang.org/protobuf v1.36.9 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.34.1 // indirect k8s.io/cli-runtime v0.34.0 // indirect k8s.io/component-base v0.34.1 // indirect diff --git a/pkg/builtinchecks/yamls/sorted-keys.yaml b/pkg/builtinchecks/yamls/sorted-keys.yaml new file mode 100644 index 000000000..69f777a6d --- /dev/null +++ b/pkg/builtinchecks/yamls/sorted-keys.yaml @@ -0,0 +1,9 @@ +name: "sorted-keys" +description: "Check that YAML keys are sorted in alphabetical order wherever possible." +remediation: "Ensure that keys in your YAML manifest are sorted in alphabetical order to improve consistency and readability." +scope: + objectKinds: + - Any +template: "sorted-keys" +params: + recursive: true diff --git a/pkg/lintcontext/mocks/context.go b/pkg/lintcontext/mocks/context.go index ee6644db5..3a02ce328 100644 --- a/pkg/lintcontext/mocks/context.go +++ b/pkg/lintcontext/mocks/context.go @@ -7,14 +7,19 @@ import ( // MockLintContext is mock implementation of the LintContext used in unit tests type MockLintContext struct { - objects map[string]k8sutil.Object + objects map[string]k8sutil.Object + rawObjects map[string][]byte } // Objects returns all the objects under this MockLintContext func (l *MockLintContext) Objects() []lintcontext.Object { result := make([]lintcontext.Object, 0, len(l.objects)) - for _, p := range l.objects { - result = append(result, lintcontext.Object{Metadata: lintcontext.ObjectMetadata{}, K8sObject: p}) + for key, p := range l.objects { + metadata := lintcontext.ObjectMetadata{} + if raw, ok := l.rawObjects[key]; ok { + metadata.Raw = raw + } + result = append(result, lintcontext.Object{Metadata: metadata, K8sObject: p}) } return result } @@ -26,10 +31,19 @@ func (l *MockLintContext) InvalidObjects() []lintcontext.InvalidObject { // NewMockContext returns an empty mockLintContext func NewMockContext() *MockLintContext { - return &MockLintContext{objects: make(map[string]k8sutil.Object)} + return &MockLintContext{ + objects: make(map[string]k8sutil.Object), + rawObjects: make(map[string][]byte), + } } // AddObject adds an object to the MockLintContext func (l *MockLintContext) AddObject(key string, obj k8sutil.Object) { l.objects[key] = obj } + +// AddObjectWithRaw adds an object to the MockLintContext with raw YAML data +func (l *MockLintContext) AddObjectWithRaw(key string, obj k8sutil.Object, raw []byte) { + l.objects[key] = obj + l.rawObjects[key] = raw +} diff --git a/pkg/templates/all/all.go b/pkg/templates/all/all.go index b6e1bd3cf..509fec858 100644 --- a/pkg/templates/all/all.go +++ b/pkg/templates/all/all.go @@ -56,6 +56,7 @@ import ( _ "golang.stackrox.io/kube-linter/pkg/templates/sccdenypriv" _ "golang.stackrox.io/kube-linter/pkg/templates/serviceaccount" _ "golang.stackrox.io/kube-linter/pkg/templates/servicetype" + _ "golang.stackrox.io/kube-linter/pkg/templates/sortedkeys" _ "golang.stackrox.io/kube-linter/pkg/templates/startupport" _ "golang.stackrox.io/kube-linter/pkg/templates/sysctl" _ "golang.stackrox.io/kube-linter/pkg/templates/targetport" diff --git a/pkg/templates/sortedkeys/internal/params/gen-params.go b/pkg/templates/sortedkeys/internal/params/gen-params.go new file mode 100644 index 000000000..47e838d86 --- /dev/null +++ b/pkg/templates/sortedkeys/internal/params/gen-params.go @@ -0,0 +1,68 @@ +// Code generated by kube-linter template codegen. DO NOT EDIT. +//go:build !templatecodegen +// +build !templatecodegen + +package params + +import ( + "fmt" + "strings" + + "golang.stackrox.io/kube-linter/pkg/check" + "golang.stackrox.io/kube-linter/pkg/templates/util" +) + +var ( + // Use some imports in case they don't get used otherwise. + _ = util.MustParseParameterDesc + + recursiveParamDesc = util.MustParseParameterDesc(`{ + "Name": "recursive", + "Type": "boolean", + "Description": "Recursive determines whether to check keys recursively at all nesting levels. Default is true.", + "Examples": null, + "Enum": null, + "SubParameters": null, + "ArrayElemType": "", + "Required": false, + "NoRegex": false, + "NotNegatable": false, + "XXXStructFieldName": "Recursive", + "XXXIsPointer": false +} +`) + + ParamDescs = []check.ParameterDesc{ + recursiveParamDesc, + } +) + +func (p *Params) Validate() error { + var validationErrors []string + if len(validationErrors) > 0 { + return fmt.Errorf("invalid parameters: %s", strings.Join(validationErrors, ", ")) + } + return nil +} + +// ParseAndValidate instantiates a Params object out of the passed map[string]interface{}, +// validates it, and returns it. +// The return type is interface{} to satisfy the type in the Template struct. +func ParseAndValidate(m map[string]interface{}) (interface{}, error) { + var p Params + if err := util.DecodeMapStructure(m, &p); err != nil { + return nil, err + } + if err := p.Validate(); err != nil { + return nil, err + } + return p, nil +} + +// WrapInstantiateFunc is a convenience wrapper that wraps an untyped instantiate function +// into a typed one. +func WrapInstantiateFunc(f func(p Params) (check.Func, error)) func(interface{}) (check.Func, error) { + return func(paramsInt interface{}) (check.Func, error) { + return f(paramsInt.(Params)) + } +} diff --git a/pkg/templates/sortedkeys/internal/params/params.go b/pkg/templates/sortedkeys/internal/params/params.go new file mode 100644 index 000000000..f7559fe2b --- /dev/null +++ b/pkg/templates/sortedkeys/internal/params/params.go @@ -0,0 +1,8 @@ +package params + +// Params represents the params accepted by this template. +type Params struct { + // Recursive determines whether to check keys recursively at all nesting levels. + // Default is true. + Recursive bool +} diff --git a/pkg/templates/sortedkeys/template.go b/pkg/templates/sortedkeys/template.go new file mode 100644 index 000000000..503e80855 --- /dev/null +++ b/pkg/templates/sortedkeys/template.go @@ -0,0 +1,135 @@ +package sortedkeys + +import ( + "fmt" + "sort" + "strings" + + "golang.stackrox.io/kube-linter/pkg/check" + "golang.stackrox.io/kube-linter/pkg/config" + "golang.stackrox.io/kube-linter/pkg/diagnostic" + "golang.stackrox.io/kube-linter/pkg/lintcontext" + "golang.stackrox.io/kube-linter/pkg/objectkinds" + "golang.stackrox.io/kube-linter/pkg/templates" + "golang.stackrox.io/kube-linter/pkg/templates/sortedkeys/internal/params" + "gopkg.in/yaml.v3" +) + +const templateKey = "sorted-keys" + +func init() { + templates.Register(check.Template{ + HumanName: "Sorted Keys", + Key: templateKey, + Description: "Flag YAML keys that are not sorted in alphabetical order", + SupportedObjectKinds: config.ObjectKindsDesc{ + ObjectKinds: []string{objectkinds.Any}, + }, + Parameters: params.ParamDescs, + ParseAndValidateParams: params.ParseAndValidate, + Instantiate: params.WrapInstantiateFunc(func(p params.Params) (check.Func, error) { + return func(_ lintcontext.LintContext, object lintcontext.Object) []diagnostic.Diagnostic { + // Parse the raw YAML to preserve key order + var node yaml.Node + if err := yaml.Unmarshal(object.Metadata.Raw, &node); err != nil { + // Skip objects that can't be parsed + return nil + } + + var diagnostics []diagnostic.Diagnostic + + // The root node is a document node, we need to check its content + if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + diagnostics = checkNode(node.Content[0], "", p.Recursive) + } else if node.Kind == yaml.MappingNode { + diagnostics = checkNode(&node, "", p.Recursive) + } + + return diagnostics + }, nil + }), + }) +} + +// checkNode recursively checks if keys in a YAML node are sorted +func checkNode(node *yaml.Node, path string, recursive bool) []diagnostic.Diagnostic { + if node == nil { + return nil + } + + var diagnostics []diagnostic.Diagnostic + + switch node.Kind { + case yaml.MappingNode: + // Extract keys from the mapping node + // In yaml.v3, mapping nodes store key-value pairs as alternating elements in Content + var keys []string + keyPositions := make(map[string]int) // Track original positions + + for i := 0; i < len(node.Content); i += 2 { + if node.Content[i].Kind == yaml.ScalarNode { + key := node.Content[i].Value + keys = append(keys, key) + keyPositions[key] = i / 2 + } + } + + // Check if keys are sorted + if len(keys) > 1 { + sortedKeys := make([]string, len(keys)) + copy(sortedKeys, keys) + sort.Strings(sortedKeys) + + // Find the first key that is out of order + for i := 0; i < len(keys); i++ { + if keys[i] != sortedKeys[i] { + location := path + if location == "" { + location = "root" + } + + diagnostics = append(diagnostics, diagnostic.Diagnostic{ + Message: fmt.Sprintf( + "Keys are not sorted at %s. Expected order: [%s], got: [%s]", + location, + strings.Join(sortedKeys, ", "), + strings.Join(keys, ", "), + ), + }) + // Only report once per level + break + } + } + } + + // Recursively check child nodes if recursive is enabled + if recursive { + for i := 0; i < len(node.Content); i += 2 { + keyNode := node.Content[i] + valueNode := node.Content[i+1] + + childPath := path + if childPath == "" { + childPath = keyNode.Value + } else { + childPath = path + "." + keyNode.Value + } + + childDiagnostics := checkNode(valueNode, childPath, recursive) + diagnostics = append(diagnostics, childDiagnostics...) + } + } + + case yaml.SequenceNode: + // For sequences, check each element if it's a mapping + if recursive { + for idx, item := range node.Content { + childPath := fmt.Sprintf("%s[%d]", path, idx) + childDiagnostics := checkNode(item, childPath, recursive) + diagnostics = append(diagnostics, childDiagnostics...) + } + } + } + + return diagnostics +} diff --git a/pkg/templates/sortedkeys/template_test.go b/pkg/templates/sortedkeys/template_test.go new file mode 100644 index 000000000..324246103 --- /dev/null +++ b/pkg/templates/sortedkeys/template_test.go @@ -0,0 +1,719 @@ +package sortedkeys + +import ( + "testing" + + "github.com/stretchr/testify/suite" + appsV1 "k8s.io/api/apps/v1" + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "golang.stackrox.io/kube-linter/pkg/diagnostic" + "golang.stackrox.io/kube-linter/pkg/lintcontext/mocks" + "golang.stackrox.io/kube-linter/pkg/templates" + "golang.stackrox.io/kube-linter/pkg/templates/sortedkeys/internal/params" +) + +func TestSortedKeys(t *testing.T) { + suite.Run(t, new(SortedKeysTestSuite)) +} + +type SortedKeysTestSuite struct { + templates.TemplateTestSuite + ctx *mocks.MockLintContext +} + +func (s *SortedKeysTestSuite) SetupTest() { + s.Init(templateKey) + s.ctx = mocks.NewMockContext() +} + +func (s *SortedKeysTestSuite) TestSortedKeys() { + const deploymentName = "sorted-deployment" + + // Create a deployment with properly sorted YAML keys + sortedYAML := []byte(`apiVersion: apps/v1 +kind: Deployment +metadata: + name: sorted-deployment +spec: + replicas: 3 +`) + + deployment := &appsV1.Deployment{ + TypeMeta: metaV1.TypeMeta{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + ObjectMeta: metaV1.ObjectMeta{ + Name: deploymentName, + }, + } + + s.ctx.AddObjectWithRaw(deploymentName, deployment, sortedYAML) + + s.Validate(s.ctx, []templates.TestCase{ + { + Param: params.Params{ + Recursive: true, + }, + Diagnostics: map[string][]diagnostic.Diagnostic{ + deploymentName: nil, // No diagnostics expected for sorted keys + }, + ExpectInstantiationError: false, + }, + }) +} + +func (s *SortedKeysTestSuite) TestUnsortedTopLevelKeys() { + const deploymentName = "unsorted-deployment" + + // Create a deployment with unsorted YAML keys (kind comes after spec) + unsortedYAML := []byte(`apiVersion: apps/v1 +metadata: + name: unsorted-deployment +spec: + replicas: 1 +kind: Deployment +`) + + deployment := &appsV1.Deployment{ + TypeMeta: metaV1.TypeMeta{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + ObjectMeta: metaV1.ObjectMeta{ + Name: deploymentName, + }, + } + + s.ctx.AddObjectWithRaw(deploymentName, deployment, unsortedYAML) + + s.Validate(s.ctx, []templates.TestCase{ + { + Param: params.Params{ + Recursive: true, + }, + Diagnostics: map[string][]diagnostic.Diagnostic{ + deploymentName: { + {Message: "Keys are not sorted at root. Expected order: [apiVersion, kind, metadata, spec], got: [apiVersion, metadata, spec, kind]"}, + }, + }, + ExpectInstantiationError: false, + }, + }) +} + +func (s *SortedKeysTestSuite) TestUnsortedNestedKeys() { + const deploymentName = "unsorted-nested" + + // Create a deployment with unsorted nested keys + unsortedNestedYAML := []byte(`apiVersion: apps/v1 +kind: Deployment +metadata: + name: unsorted-nested +spec: + template: + spec: + containers: + - name: app + image: myapp:latest + metadata: + labels: + app: myapp +`) + + deployment := &appsV1.Deployment{ + TypeMeta: metaV1.TypeMeta{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + ObjectMeta: metaV1.ObjectMeta{ + Name: deploymentName, + }, + } + + s.ctx.AddObjectWithRaw(deploymentName, deployment, unsortedNestedYAML) + + s.Validate(s.ctx, []templates.TestCase{ + { + Param: params.Params{ + Recursive: true, + }, + Diagnostics: map[string][]diagnostic.Diagnostic{ + deploymentName: { + {Message: "Keys are not sorted at spec.template. Expected order: [metadata, spec], got: [spec, metadata]"}, + {Message: "Keys are not sorted at spec.template.spec.containers[0]. Expected order: [image, name], got: [name, image]"}, + }, + }, + ExpectInstantiationError: false, + }, + }) +} + +func (s *SortedKeysTestSuite) TestNonRecursiveCheck() { + const deploymentName = "non-recursive" + + // Create a deployment with unsorted nested keys but recursive=false + unsortedNestedYAML := []byte(`apiVersion: apps/v1 +kind: Deployment +metadata: + name: non-recursive +spec: + template: + spec: + containers: + - name: app + metadata: + labels: + app: myapp +`) + + deployment := &appsV1.Deployment{ + TypeMeta: metaV1.TypeMeta{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + ObjectMeta: metaV1.ObjectMeta{ + Name: deploymentName, + }, + } + + s.ctx.AddObjectWithRaw(deploymentName, deployment, unsortedNestedYAML) + + s.Validate(s.ctx, []templates.TestCase{ + { + Param: params.Params{ + Recursive: false, + }, + Diagnostics: map[string][]diagnostic.Diagnostic{ + deploymentName: nil, // Top-level keys are sorted, nested unsorted keys ignored + }, + ExpectInstantiationError: false, + }, + }) +} + +func (s *SortedKeysTestSuite) TestConfigMapUnsortedData() { + const configMapName = "unsorted-configmap" + + // ConfigMap with unsorted data keys + unsortedDataYAML := []byte(`apiVersion: v1 +data: + zebra: "z" + apple: "a" +kind: ConfigMap +metadata: + name: unsorted-configmap +`) + + // Note: We need to use k8s types, but for simplicity in this test + // we'll use a deployment as a stand-in since the check works on raw YAML + deployment := &appsV1.Deployment{ + TypeMeta: metaV1.TypeMeta{ + APIVersion: "v1", + Kind: "ConfigMap", + }, + ObjectMeta: metaV1.ObjectMeta{ + Name: configMapName, + }, + } + + s.ctx.AddObjectWithRaw(configMapName, deployment, unsortedDataYAML) + + s.Validate(s.ctx, []templates.TestCase{ + { + Param: params.Params{ + Recursive: true, + }, + Diagnostics: map[string][]diagnostic.Diagnostic{ + configMapName: { + // Data keys are unsorted (zebra before apple) + {Message: "Keys are not sorted at data. Expected order: [apple, zebra], got: [zebra, apple]"}, + }, + }, + ExpectInstantiationError: false, + }, + }) +} + +func (s *SortedKeysTestSuite) TestDeeplyNestedStructure() { + const deploymentName = "deeply-nested" + + // Test deeply nested structure (4 levels) with unsorted keys at level 3 + deeplyNestedYAML := []byte(`apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + kubernetes.io/change-cause: "updated image" + app.kubernetes.io/version: "1.0.0" + labels: + app: myapp + name: deeply-nested +spec: + selector: + matchLabels: + app: myapp + template: + metadata: + labels: + app: myapp + spec: + containers: + - env: + - name: LOG_LEVEL + value: debug + - name: APP_MODE + value: production + image: myapp:latest + name: main + resources: + requests: + memory: "64Mi" + cpu: "100m" + limits: + memory: "128Mi" + cpu: "500m" +`) + + deployment := &appsV1.Deployment{ + TypeMeta: metaV1.TypeMeta{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + ObjectMeta: metaV1.ObjectMeta{ + Name: deploymentName, + }, + } + + s.ctx.AddObjectWithRaw(deploymentName, deployment, deeplyNestedYAML) + + s.Validate(s.ctx, []templates.TestCase{ + { + Param: params.Params{ + Recursive: true, + }, + Diagnostics: map[string][]diagnostic.Diagnostic{ + deploymentName: { + // annotations keys unsorted + {Message: "Keys are not sorted at metadata.annotations. Expected order: [app.kubernetes.io/version, kubernetes.io/change-cause], got: [kubernetes.io/change-cause, app.kubernetes.io/version]"}, + // resources keys unsorted (requests before limits) + {Message: "Keys are not sorted at spec.template.spec.containers[0].resources. Expected order: [limits, requests], got: [requests, limits]"}, + // resources.requests keys unsorted + {Message: "Keys are not sorted at spec.template.spec.containers[0].resources.requests. Expected order: [cpu, memory], got: [memory, cpu]"}, + // resources.limits keys unsorted + {Message: "Keys are not sorted at spec.template.spec.containers[0].resources.limits. Expected order: [cpu, memory], got: [memory, cpu]"}, + }, + }, + ExpectInstantiationError: false, + }, + }) +} + +func (s *SortedKeysTestSuite) TestMultipleContainersInArray() { + const deploymentName = "multi-container" + + // Test multiple containers with various unsorted patterns + multiContainerYAML := []byte(`apiVersion: apps/v1 +kind: Deployment +metadata: + name: multi-container +spec: + template: + metadata: + labels: + app: multi + spec: + containers: + - name: nginx + image: nginx:latest + ports: + - protocol: TCP + containerPort: 80 + name: http + - name: sidecar + image: sidecar:latest + volumeMounts: + - name: data + mountPath: /data + - image: logger:latest + name: logger + volumes: + - name: data + emptyDir: {} +`) + + deployment := &appsV1.Deployment{ + TypeMeta: metaV1.TypeMeta{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + ObjectMeta: metaV1.ObjectMeta{ + Name: deploymentName, + }, + } + + s.ctx.AddObjectWithRaw(deploymentName, deployment, multiContainerYAML) + + s.Validate(s.ctx, []templates.TestCase{ + { + Param: params.Params{ + Recursive: true, + }, + Diagnostics: map[string][]diagnostic.Diagnostic{ + deploymentName: { + // First container keys unsorted (name before image) + {Message: "Keys are not sorted at spec.template.spec.containers[0]. Expected order: [image, name, ports], got: [name, image, ports]"}, + // First container ports unsorted + {Message: "Keys are not sorted at spec.template.spec.containers[0].ports[0]. Expected order: [containerPort, name, protocol], got: [protocol, containerPort, name]"}, + // Second container keys unsorted (name before image) + {Message: "Keys are not sorted at spec.template.spec.containers[1]. Expected order: [image, name, volumeMounts], got: [name, image, volumeMounts]"}, + // Second container volumeMounts unsorted + {Message: "Keys are not sorted at spec.template.spec.containers[1].volumeMounts[0]. Expected order: [mountPath, name], got: [name, mountPath]"}, + // volumes unsorted (name before emptyDir) + {Message: "Keys are not sorted at spec.template.spec.volumes[0]. Expected order: [emptyDir, name], got: [name, emptyDir]"}, + }, + }, + ExpectInstantiationError: false, + }, + }) +} + +func (s *SortedKeysTestSuite) TestEdgeCaseEmptyAndSingleKey() { + const deploymentName = "edge-cases" + + // Test edge cases: empty objects and single key objects + edgeCaseYAML := []byte(`apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: {} + labels: + single: value + name: edge-cases +spec: + replicas: 1 +`) + + deployment := &appsV1.Deployment{ + TypeMeta: metaV1.TypeMeta{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + ObjectMeta: metaV1.ObjectMeta{ + Name: deploymentName, + }, + } + + s.ctx.AddObjectWithRaw(deploymentName, deployment, edgeCaseYAML) + + s.Validate(s.ctx, []templates.TestCase{ + { + Param: params.Params{ + Recursive: true, + }, + Diagnostics: map[string][]diagnostic.Diagnostic{ + deploymentName: nil, // No errors - empty and single key objects are fine + }, + ExpectInstantiationError: false, + }, + }) +} + +func (s *SortedKeysTestSuite) TestNumericAndSpecialCharKeys() { + const configMapName = "numeric-keys" + + // Test numeric keys and special characters - should sort lexicographically + numericKeysYAML := []byte(`apiVersion: v1 +data: + "10-config": value10 + "2-config": value2 + "1-config": value1 + config-z: valuez + config-a: valuea +kind: ConfigMap +metadata: + name: numeric-keys +`) + + deployment := &appsV1.Deployment{ + TypeMeta: metaV1.TypeMeta{ + APIVersion: "v1", + Kind: "ConfigMap", + }, + ObjectMeta: metaV1.ObjectMeta{ + Name: configMapName, + }, + } + + s.ctx.AddObjectWithRaw(configMapName, deployment, numericKeysYAML) + + s.Validate(s.ctx, []templates.TestCase{ + { + Param: params.Params{ + Recursive: true, + }, + Diagnostics: map[string][]diagnostic.Diagnostic{ + configMapName: { + // Numeric keys should be sorted lexicographically: "1-config", "10-config", "2-config", "config-a", "config-z" + {Message: "Keys are not sorted at data. Expected order: [1-config, 10-config, 2-config, config-a, config-z], got: [10-config, 2-config, 1-config, config-z, config-a]"}, + }, + }, + ExpectInstantiationError: false, + }, + }) +} + +func (s *SortedKeysTestSuite) TestComplexServiceWithSelectors() { + const serviceName = "complex-service" + + // Real-world Service manifest with multiple sections + serviceYAML := []byte(`apiVersion: v1 +kind: Service +metadata: + annotations: + service.beta.kubernetes.io/aws-load-balancer-type: nlb + labels: + app: web + name: complex-service +spec: + ports: + - port: 443 + targetPort: 8443 + protocol: TCP + name: https + - name: http + port: 80 + protocol: TCP + targetPort: 8080 + selector: + app: web + type: LoadBalancer +`) + + deployment := &appsV1.Deployment{ + TypeMeta: metaV1.TypeMeta{ + APIVersion: "v1", + Kind: "Service", + }, + ObjectMeta: metaV1.ObjectMeta{ + Name: serviceName, + }, + } + + s.ctx.AddObjectWithRaw(serviceName, deployment, serviceYAML) + + s.Validate(s.ctx, []templates.TestCase{ + { + Param: params.Params{ + Recursive: true, + }, + Diagnostics: map[string][]diagnostic.Diagnostic{ + serviceName: { + // First port keys unsorted + {Message: "Keys are not sorted at spec.ports[0]. Expected order: [name, port, protocol, targetPort], got: [port, targetPort, protocol, name]"}, + }, + }, + ExpectInstantiationError: false, + }, + }) +} + +func (s *SortedKeysTestSuite) TestMixedSortedAndUnsorted() { + const deploymentName = "mixed-sorting" + + // Some levels sorted, some not + mixedYAML := []byte(`apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: test + version: v1 + name: mixed-sorting +spec: + selector: + matchLabels: + version: v1 + app: test + template: + metadata: + labels: + app: test + spec: + containers: + - image: test:latest + name: test +`) + + deployment := &appsV1.Deployment{ + TypeMeta: metaV1.TypeMeta{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + ObjectMeta: metaV1.ObjectMeta{ + Name: deploymentName, + }, + } + + s.ctx.AddObjectWithRaw(deploymentName, deployment, mixedYAML) + + s.Validate(s.ctx, []templates.TestCase{ + { + Param: params.Params{ + Recursive: true, + }, + Diagnostics: map[string][]diagnostic.Diagnostic{ + deploymentName: { + // metadata.labels is sorted (app, version) ✓ + // spec.selector.matchLabels is unsorted (version, app) ✗ + {Message: "Keys are not sorted at spec.selector.matchLabels. Expected order: [app, version], got: [version, app]"}, + }, + }, + ExpectInstantiationError: false, + }, + }) +} + +func (s *SortedKeysTestSuite) TestComplexPodSpecWithVolumes() { + const deploymentName = "complex-pod-spec" + + // Complex pod spec with init containers, volumes, security context + complexPodYAML := []byte(`apiVersion: apps/v1 +kind: Deployment +metadata: + name: complex-pod-spec +spec: + template: + metadata: + labels: + app: complex + spec: + containers: + - image: app:latest + name: app + volumeMounts: + - mountPath: /data + name: data-volume + securityContext: + runAsNonRoot: true + allowPrivilegeEscalation: false + initContainers: + - command: + - sh + - -c + - echo init + image: busybox:latest + name: init + serviceAccountName: app-sa + securityContext: + fsGroup: 1000 + runAsUser: 1000 + runAsGroup: 1000 + volumes: + - name: data-volume + persistentVolumeClaim: + claimName: data-pvc +`) + + deployment := &appsV1.Deployment{ + TypeMeta: metaV1.TypeMeta{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + ObjectMeta: metaV1.ObjectMeta{ + Name: deploymentName, + }, + } + + s.ctx.AddObjectWithRaw(deploymentName, deployment, complexPodYAML) + + s.Validate(s.ctx, []templates.TestCase{ + { + Param: params.Params{ + Recursive: true, + }, + Diagnostics: map[string][]diagnostic.Diagnostic{ + deploymentName: { + // spec.template.spec keys unsorted (serviceAccountName before securityContext) + {Message: "Keys are not sorted at spec.template.spec. Expected order: [containers, initContainers, securityContext, serviceAccountName, volumes], got: [containers, initContainers, serviceAccountName, securityContext, volumes]"}, + // containers[0] keys unsorted (volumeMounts before securityContext) + {Message: "Keys are not sorted at spec.template.spec.containers[0]. Expected order: [image, name, securityContext, volumeMounts], got: [image, name, volumeMounts, securityContext]"}, + // containers[0].securityContext unsorted + {Message: "Keys are not sorted at spec.template.spec.containers[0].securityContext. Expected order: [allowPrivilegeEscalation, runAsNonRoot], got: [runAsNonRoot, allowPrivilegeEscalation]"}, + // spec.template.spec.securityContext unsorted (runAsUser before runAsGroup) + {Message: "Keys are not sorted at spec.template.spec.securityContext. Expected order: [fsGroup, runAsGroup, runAsUser], got: [fsGroup, runAsUser, runAsGroup]"}, + }, + }, + ExpectInstantiationError: false, + }, + }) +} + +func (s *SortedKeysTestSuite) TestAllKeysSortedComplex() { + const deploymentName = "all-sorted-complex" + + // Complex manifest with everything sorted correctly + allSortedYAML := []byte(`apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + app.kubernetes.io/version: "1.0" + kubernetes.io/description: "test app" + labels: + app: test + environment: prod + name: all-sorted-complex +spec: + replicas: 3 + selector: + matchLabels: + app: test + template: + metadata: + labels: + app: test + spec: + containers: + - env: + - name: ENV + value: prod + image: test:latest + name: main + ports: + - containerPort: 8080 + name: http + protocol: TCP + resources: + limits: + cpu: "1" + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + volumes: + - emptyDir: {} + name: cache +`) + + deployment := &appsV1.Deployment{ + TypeMeta: metaV1.TypeMeta{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + ObjectMeta: metaV1.ObjectMeta{ + Name: deploymentName, + }, + } + + s.ctx.AddObjectWithRaw(deploymentName, deployment, allSortedYAML) + + s.Validate(s.ctx, []templates.TestCase{ + { + Param: params.Params{ + Recursive: true, + }, + Diagnostics: map[string][]diagnostic.Diagnostic{ + deploymentName: nil, // Everything is properly sorted + }, + ExpectInstantiationError: false, + }, + }) +} diff --git a/tests/checks/sorted-keys.yaml b/tests/checks/sorted-keys.yaml new file mode 100644 index 000000000..ef978bd02 --- /dev/null +++ b/tests/checks/sorted-keys.yaml @@ -0,0 +1,508 @@ +# Test case 1: Keys are properly sorted - should NOT fire +apiVersion: apps/v1 +kind: Deployment +metadata: + name: sorted-deployment +spec: + replicas: 3 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ports: + - containerPort: 80 +--- +# Test case 2: Top-level keys not sorted - SHOULD fire +apiVersion: apps/v1 +metadata: + name: unsorted-top-level +spec: + replicas: 1 +kind: Deployment # "kind" should come before "metadata" +--- +# Test case 3: Nested keys not sorted - SHOULD fire +apiVersion: apps/v1 +kind: Deployment +metadata: + name: unsorted-nested +spec: + template: + spec: + containers: + - name: app + image: myapp:latest + metadata: # "metadata" should come before "spec" + labels: + app: myapp +--- +# Test case 4: Spec-level keys not sorted - SHOULD fire +apiVersion: apps/v1 +kind: Deployment +metadata: + name: unsorted-spec-keys +spec: + template: + metadata: + labels: + app: test + spec: + containers: + - name: test + image: test:latest + selector: + matchLabels: + app: test + replicas: 2 # Keys in spec not sorted: should be replicas, selector, template +--- +# Test case 5: Multiple levels with unsorted keys - SHOULD fire +apiVersion: v1 +kind: ConfigMap +data: + zebra: "z" + apple: "a" # "apple" should come before "zebra" +metadata: + name: unsorted-configmap +--- +# Test case 6: Complex StatefulSet with volumeClaimTemplates - sorted correctly +apiVersion: apps/v1 +kind: StatefulSet +metadata: + labels: + app: postgres + name: postgres-statefulset + namespace: database +spec: + replicas: 3 + selector: + matchLabels: + app: postgres + serviceName: postgres-service + template: + metadata: + labels: + app: postgres + version: "13" + spec: + containers: + - env: + - name: POSTGRES_DB + value: mydb + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + key: password + name: postgres-secret + - name: POSTGRES_USER + value: admin + image: postgres:13 + name: postgres + ports: + - containerPort: 5432 + name: postgres + resources: + limits: + cpu: "2" + memory: 4Gi + requests: + cpu: "1" + memory: 2Gi + volumeMounts: + - mountPath: /var/lib/postgresql/data + name: postgres-storage + initContainers: + - command: + - sh + - -c + - chown -R 999:999 /var/lib/postgresql/data + image: busybox + name: init-permissions + volumeMounts: + - mountPath: /var/lib/postgresql/data + name: postgres-storage + volumeClaimTemplates: + - metadata: + name: postgres-storage + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + storageClassName: fast-ssd +--- +# Test case 7: Service with unsorted keys - SHOULD fire +apiVersion: v1 +kind: Service +metadata: + annotations: + service.beta.kubernetes.io/aws-load-balancer-type: nlb + name: frontend-service +spec: + type: LoadBalancer # type should come after selector + selector: + app: frontend + sessionAffinity: ClientIP + ports: + - name: http + port: 80 + targetPort: 8080 + protocol: TCP + - port: 443 + targetPort: 8443 + protocol: TCP + name: https # name should come before port +--- +# Test case 8: Ingress with multiple rules - sorted correctly +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/rewrite-target: / + name: app-ingress + namespace: production +spec: + ingressClassName: nginx + rules: + - host: api.example.com + http: + paths: + - backend: + service: + name: api-service + port: + number: 80 + path: /api + pathType: Prefix + - host: web.example.com + http: + paths: + - backend: + service: + name: web-service + port: + number: 80 + path: / + pathType: Prefix + tls: + - hosts: + - api.example.com + - web.example.com + secretName: tls-secret +--- +# Test case 9: CronJob with unsorted keys - SHOULD fire +apiVersion: batch/v1 +kind: CronJob +metadata: + name: backup-job +spec: + schedule: "0 2 * * *" + jobTemplate: + spec: + template: + spec: + restartPolicy: OnFailure + containers: + - name: backup + image: backup-tool:latest + volumeMounts: + - name: backup-storage + mountPath: /backups + env: + - value: "production" + name: ENVIRONMENT # name should come before value + volumes: + - name: backup-storage + persistentVolumeClaim: + claimName: backup-pvc + concurrencyPolicy: Forbid # concurrencyPolicy should come before jobTemplate +--- +# Test case 10: NetworkPolicy with complex rules - sorted correctly +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: api-network-policy + namespace: production +spec: + egress: + - ports: + - port: 5432 + protocol: TCP + to: + - namespaceSelector: + matchLabels: + name: database + - ports: + - port: 6379 + protocol: TCP + to: + - podSelector: + matchLabels: + app: redis + ingress: + - from: + - namespaceSelector: + matchLabels: + name: frontend + - podSelector: + matchLabels: + app: web + ports: + - port: 8080 + protocol: TCP + podSelector: + matchLabels: + app: api + policyTypes: + - Egress + - Ingress +--- +# Test case 11: DaemonSet with unsorted tolerations - SHOULD fire +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: log-collector +spec: + selector: + matchLabels: + app: log-collector + template: + metadata: + labels: + app: log-collector + spec: + containers: + - name: fluentd + image: fluentd:latest + volumeMounts: + - name: varlog + mountPath: /var/log + - mountPath: /var/lib/docker/containers + name: varlibdockercontainers # mountPath should come before name + resources: + requests: + memory: 200Mi + cpu: 100m + limits: + memory: 500Mi + cpu: 200m + tolerations: + - effect: NoSchedule + key: node-role.kubernetes.io/master + - key: node.kubernetes.io/disk-pressure + operator: Exists + effect: NoSchedule # effect should come before operator + volumes: + - hostPath: + path: /var/log + name: varlog + - hostPath: + path: /var/lib/docker/containers + name: varlibdockercontainers +--- +# Test case 12: HorizontalPodAutoscaler with custom metrics - sorted correctly +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: api-hpa + namespace: production +spec: + behavior: + scaleDown: + policies: + - periodSeconds: 60 + type: Pods + value: 2 + stabilizationWindowSeconds: 300 + scaleUp: + policies: + - periodSeconds: 30 + type: Percent + value: 50 + stabilizationWindowSeconds: 0 + maxReplicas: 10 + metrics: + - resource: + name: cpu + target: + averageUtilization: 70 + type: Utilization + type: Resource + - resource: + name: memory + target: + averageUtilization: 80 + type: Utilization + type: Resource + minReplicas: 2 + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: api-deployment +--- +# Test case 13: Job with complex pod spec - unsorted volumes - SHOULD fire +apiVersion: batch/v1 +kind: Job +metadata: + name: data-migration +spec: + backoffLimit: 3 + template: + spec: + containers: + - command: + - /bin/sh + - -c + - python migrate.py + env: + - name: DB_HOST + value: postgres.database.svc + - valueFrom: + secretKeyRef: + name: db-secret + key: password + name: DB_PASSWORD # name should come before valueFrom + image: migration-tool:v2.0 + name: migrate + volumeMounts: + - mountPath: /config + name: config + - mountPath: /data + name: data + restartPolicy: Never + volumes: + - name: config + configMap: + name: migration-config + - name: data + emptyDir: {} + - persistentVolumeClaim: + claimName: data-pvc + name: persistent-data # name should come before persistentVolumeClaim +--- +# Test case 14: Complex Deployment with all features - sorted correctly +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployment.kubernetes.io/revision: "3" + labels: + app: web-app + environment: production + team: platform + name: web-app-deployment + namespace: production +spec: + minReadySeconds: 30 + progressDeadlineSeconds: 600 + replicas: 5 + revisionHistoryLimit: 10 + selector: + matchLabels: + app: web-app + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + type: RollingUpdate + template: + metadata: + annotations: + prometheus.io/port: "9090" + prometheus.io/scrape: "true" + labels: + app: web-app + version: v1.2.3 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app: web-app + topologyKey: kubernetes.io/hostname + weight: 100 + containers: + - env: + - name: APP_ENV + value: production + - name: LOG_LEVEL + value: info + - name: SECRET_KEY + valueFrom: + secretKeyRef: + key: secret-key + name: app-secrets + image: myapp:v1.2.3 + imagePullPolicy: IfNotPresent + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 + name: web-app + ports: + - containerPort: 8080 + name: http + protocol: TCP + - containerPort: 9090 + name: metrics + protocol: TCP + readinessProbe: + httpGet: + path: /ready + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 + resources: + limits: + cpu: "1" + memory: 1Gi + requests: + cpu: 500m + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + volumeMounts: + - mountPath: /config + name: config + readOnly: true + - mountPath: /tmp + name: tmp + imagePullSecrets: + - name: registry-credentials + nodeSelector: + disktype: ssd + node.kubernetes.io/instance-type: m5.large + securityContext: + fsGroup: 1000 + runAsGroup: 1000 + runAsUser: 1000 + serviceAccountName: web-app-sa + terminationGracePeriodSeconds: 60 + tolerations: + - effect: NoSchedule + key: dedicated + operator: Equal + value: frontend + volumes: + - configMap: + name: app-config + name: config + - emptyDir: {} + name: tmp