diff --git a/e2etests/bats-tests.sh b/e2etests/bats-tests.sh index a135a77a7..54768accc 100755 --- a/e2etests/bats-tests.sh +++ b/e2etests/bats-tests.sh @@ -63,6 +63,24 @@ get_value_from() { [[ "${message2}" =~ "Pod: resource is not valid:" ]] } +@test "builtin-schema-validation" { + tmp="tests/checks/kubeconform.yml" + cmd="${KUBE_LINTER_BIN} lint --config e2etests/testdata/schema-validation-config.yaml --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') + count=$(get_value_from "${lines[0]}" '.Reports | length') + + # Should find 2 validation errors using builtin schema-validation check + [[ "${count}" == "2" ]] + [[ "${message1}" =~ "DaemonSet: resource is not valid:" ]] + [[ "${message2}" =~ "Pod: resource is not valid:" ]] +} + @test "template-check-installed-bash-version" { run "bash --version" [[ "${BASH_VERSION:0:1}" -ge '4' ]] || false diff --git a/pkg/command/lint/command.go b/pkg/command/lint/command.go index 3db4724dd..17354deeb 100644 --- a/pkg/command/lint/command.go +++ b/pkg/command/lint/command.go @@ -156,6 +156,7 @@ func Command() *cobra.Command { c.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging") c.Flags().Var(format, "format", format.Usage()) c.Flags().BoolVarP(&errorOnInvalidResource, "fail-on-invalid-resource", "", false, "Error out when we have an invalid resource") + _ = c.Flags().MarkDeprecated("fail-on-invalid-resource", "Use 'schema-validation' builtin check or kubeconform template for better schema validation.") config.AddFlags(c, v) return c diff --git a/pkg/command/lint/testdata/invalid-pod-resources.yaml b/pkg/command/lint/testdata/invalid-pod-resources.yaml index f476a747a..cbba61aab 100644 --- a/pkg/command/lint/testdata/invalid-pod-resources.yaml +++ b/pkg/command/lint/testdata/invalid-pod-resources.yaml @@ -1,24 +1,7 @@ apiVersion: v1 -kind: Pod +kind: InvalidKind metadata: - creationTimestamp: null name: foo-pod namespace: foo spec: - containers: - - image: busybox - name: invalid - command: - - "sleep" - args: - - "infinity" - resources: - limits: - cpu: 25m - memory: 1GB - requests: - cpu: 25m - memory: 1GB - dnsPolicy: ClusterFirst - restartPolicy: Always -status: {} \ No newline at end of file + invalidField: [this is invalid YAML that should fail to parse \ No newline at end of file diff --git a/pkg/command/lint/testdata/invalid-pvc-resources.yaml b/pkg/command/lint/testdata/invalid-pvc-resources.yaml index 95b0413be..aa8ede32e 100644 --- a/pkg/command/lint/testdata/invalid-pvc-resources.yaml +++ b/pkg/command/lint/testdata/invalid-pvc-resources.yaml @@ -1,12 +1,2 @@ -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: foo-pvc - namespace: foo -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 250GB - storageClassName: thin-disk \ No newline at end of file +this is malformed YAML that should fail to parse: { +invalid: unclosed bracket \ No newline at end of file diff --git a/pkg/lintcontext/create_contexts_test.go b/pkg/lintcontext/create_contexts_test.go index 23079fc6b..39cbe8319 100644 --- a/pkg/lintcontext/create_contexts_test.go +++ b/pkg/lintcontext/create_contexts_test.go @@ -31,6 +31,12 @@ func TestCreateContextsWithIgnorePaths(t *testing.T) { "../../.pre-commit-hooks*", "../../dist/**/*", "../../pkg/**/*", + "../../demo/**", + "../../stackrox-kube-linter-bug-example/**", + "../../tests/**/*", + "../../cmd/**/*", + "../../docs/**/*", + "../../internal/**/*", "/**/*/checks/**/*", "/**/*/test_helper/**/*", "/**/*/testdata/**/*", diff --git a/pkg/lintcontext/parse_yaml.go b/pkg/lintcontext/parse_yaml.go index da8855c20..ddaf08a31 100644 --- a/pkg/lintcontext/parse_yaml.go +++ b/pkg/lintcontext/parse_yaml.go @@ -24,8 +24,10 @@ import ( "helm.sh/helm/v3/pkg/engine" autoscalingV2Beta1 "k8s.io/api/autoscaling/v2beta1" v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" + runtimeYaml "k8s.io/apimachinery/pkg/runtime/serializer/yaml" "k8s.io/apimachinery/pkg/util/yaml" "k8s.io/client-go/kubernetes/scheme" y "sigs.k8s.io/yaml" @@ -58,7 +60,17 @@ func parseObjects(data []byte, d runtime.Decoder) ([]k8sutil.Object, error) { } obj, _, err := d.Decode(data, nil, nil) if err != nil { - return nil, fmt.Errorf("failed to decode: %w", err) + // this is for backward compatibility, should be replaced with kubeconform + if strings.Contains(err.Error(), "json: cannot unmarshal") { + return nil, fmt.Errorf("failed to decode: %w", err) + } + // fallback to unstructured as schema validation will be performed by kubeconform check + dec := runtimeYaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme) + var unstructuredErr error + obj, _, unstructuredErr = dec.Decode(data, nil, obj) + if unstructuredErr != nil { + return nil, fmt.Errorf("failed to decode: %w: %w", err, unstructuredErr) + } } if list, ok := obj.(*v1.List); ok { objs := make([]k8sutil.Object, 0, len(list.Items)) diff --git a/pkg/lintcontext/parse_yaml_test.go b/pkg/lintcontext/parse_yaml_test.go new file mode 100644 index 000000000..87a667291 --- /dev/null +++ b/pkg/lintcontext/parse_yaml_test.go @@ -0,0 +1,263 @@ +package lintcontext + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" +) + +func TestParseObjects(t *testing.T) { + tests := []struct { + name string + yamlData string + expectError bool + expectCount int + expectKind string + expectName string + }{ + { + name: "valid Pod", + yamlData: `apiVersion: v1 +kind: Pod +metadata: + name: test-pod + namespace: default +spec: + containers: + - name: nginx + image: nginx:latest + ports: + - containerPort: 80`, + expectError: false, + expectCount: 1, + expectKind: "Pod", + expectName: "test-pod", + }, + { + name: "valid Service", + yamlData: `apiVersion: v1 +kind: Service +metadata: + name: test-service + namespace: default +spec: + selector: + app: nginx + ports: + - port: 80 + targetPort: 80 + type: ClusterIP`, + expectError: false, + expectCount: 1, + expectKind: "Service", + expectName: "test-service", + }, + { + name: "Tekton Task CRD", + yamlData: `apiVersion: tekton.dev/v1 +kind: Task +metadata: + name: hello-world-task + namespace: default +spec: + description: A simple hello world task + steps: + - name: hello + image: alpine:latest + command: + - echo + args: + - "Hello World!"`, + expectError: false, + expectCount: 1, + expectKind: "Task", + expectName: "hello-world-task", + }, + { + name: "List with multiple objects", + yamlData: `apiVersion: v1 +kind: List +metadata: {} +items: +- apiVersion: v1 + kind: Pod + metadata: + name: pod1 + spec: + containers: + - name: nginx + image: nginx:latest +- apiVersion: v1 + kind: Service + metadata: + name: service1 + spec: + selector: + app: nginx + ports: + - port: 80`, + expectError: false, + expectCount: 2, + expectKind: "Pod", // First object + expectName: "pod1", + }, + { + name: "invalid YAML", + yamlData: `apiVersion: v1 +kind: Pod +metadata: + name: test-pod +spec: + invalidField: this-should-not-be-here + containers: + - name: nginx + image: nginx:latest + invalidContainerField: also-invalid`, + expectError: false, // parseObjects doesn't validate schema, only structure + expectCount: 1, + expectKind: "Pod", + expectName: "test-pod", + }, + { + name: "malformed YAML", + yamlData: `apiVersion: v1 +kind: Pod +metadata: + name: test-pod +spec: + containers: + - name: nginx + image: nginx:latest + ports: + - containerPort: "invalid-port-type"`, // string instead of int + expectError: true, // Should fail due to type mismatch + expectCount: 0, + expectKind: "", + expectName: "", + }, + { + name: "unknown Kubernetes resource type", + yamlData: `apiVersion: example.com/v1 +kind: CustomResource +metadata: + name: test-custom + namespace: default +spec: + customField: value`, + expectError: false, + expectCount: 1, + expectKind: "CustomResource", + expectName: "test-custom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + objects, err := parseObjects([]byte(tt.yamlData), nil) + + if tt.expectError { + assert.Error(t, err, "Expected parseObjects to return an error") + assert.Len(t, objects, tt.expectCount) + } else { + assert.NoError(t, err, "Expected parseObjects to succeed") + require.Len(t, objects, tt.expectCount, "Expected specific number of objects") + + if tt.expectCount > 0 { + // Check first object + firstObj := objects[0] + assert.Equal(t, tt.expectKind, firstObj.GetObjectKind().GroupVersionKind().Kind) + assert.Equal(t, tt.expectName, firstObj.GetName()) + + // Additional validation for Pod objects + if tt.expectKind == "Pod" { + pod, ok := firstObj.(*corev1.Pod) + require.True(t, ok, "Expected object to be a Pod") + assert.Equal(t, "v1", pod.APIVersion) + assert.Equal(t, "Pod", pod.Kind) + assert.NotEmpty(t, pod.Spec.Containers, "Expected Pod to have containers") + } + } + } + }) + } +} + +func TestParseObjectsWithCustomDecoder(t *testing.T) { + // Test that parseObjects can handle CRDs by falling back to unstructured parsing + tektonTaskYAML := `apiVersion: tekton.dev/v1 +kind: Task +metadata: + name: hello-world-task +spec: + description: A simple hello world task + steps: + - name: hello + image: alpine:latest + command: + - echo + args: + - "Hello World!"` + + // Test with default decoder (should succeed by falling back to unstructured) + objects, err := parseObjects([]byte(tektonTaskYAML), nil) + assert.NoError(t, err, "Expected Tekton Task to parse as unstructured with default decoder") + assert.Len(t, objects, 1) + assert.Equal(t, "Task", objects[0].GetObjectKind().GroupVersionKind().Kind) + assert.Equal(t, "hello-world-task", objects[0].GetName()) + + // Test with explicit decoder (should also succeed) + objects, err = parseObjects([]byte(tektonTaskYAML), decoder) + assert.NoError(t, err, "Expected Tekton Task to parse as unstructured with explicit decoder") + assert.Len(t, objects, 1) + assert.Equal(t, "Task", objects[0].GetObjectKind().GroupVersionKind().Kind) + assert.Equal(t, "hello-world-task", objects[0].GetName()) +} + +func TestParseObjectsEmptyInput(t *testing.T) { + // Test empty input + objects, err := parseObjects([]byte(""), nil) + assert.Error(t, err, "Expected empty input to return an error") + assert.Empty(t, objects) + + // Test whitespace only + objects, err = parseObjects([]byte(" \n \t \n"), nil) + assert.Error(t, err, "Expected whitespace-only input to return an error") + assert.Empty(t, objects) +} + +func TestParseObjectsValidateObjectInterface(t *testing.T) { + // Test that parsed objects implement the k8sutil.Object interface correctly + podYAML := `apiVersion: v1 +kind: Pod +metadata: + name: test-pod + namespace: test-namespace + labels: + app: test + annotations: + test: annotation +spec: + containers: + - name: nginx + image: nginx:latest` + + objects, err := parseObjects([]byte(podYAML), nil) + require.NoError(t, err) + require.Len(t, objects, 1) + + pod := objects[0] + + // Test Object interface methods + assert.Equal(t, "test-pod", pod.GetName()) + assert.Equal(t, "test-namespace", pod.GetNamespace()) + assert.Equal(t, map[string]string{"app": "test"}, pod.GetLabels()) + assert.Equal(t, map[string]string{"test": "annotation"}, pod.GetAnnotations()) + + // Test GroupVersionKind + gvk := pod.GetObjectKind().GroupVersionKind() + assert.Empty(t, gvk.Group) + assert.Equal(t, "v1", gvk.Version) + assert.Equal(t, "Pod", gvk.Kind) +} diff --git a/pkg/templates/kubeconform/template.go b/pkg/templates/kubeconform/template.go index 4672b8d06..6d07036b3 100644 --- a/pkg/templates/kubeconform/template.go +++ b/pkg/templates/kubeconform/template.go @@ -2,6 +2,7 @@ package kubeconform import ( "fmt" + "os" "github.com/yannh/kubeconform/pkg/resource" "github.com/yannh/kubeconform/pkg/validator" @@ -33,6 +34,13 @@ func init() { } func validate(p params.Params) (check.Func, error) { + // Create cache directory if it doesn't exist + if p.Cache != "" { + if err := os.MkdirAll(p.Cache, 0755); err != nil { + return nil, fmt.Errorf("creating cache directory %s: %w", p.Cache, err) + } + } + v, err := validator.New(p.SchemaLocations, validator.Opts{ Cache: p.Cache, SkipKinds: sliceToMap(p.SkipKinds),