From 6fe9cb0d6fc77cf98a7e95ecbeabdd109b4625b9 Mon Sep 17 00:00:00 2001 From: Nikolay Mitrofanov Date: Fri, 16 Jan 2026 22:22:32 +0300 Subject: [PATCH] SchemaIndex: add ParseIndex func and methods for extract group and group version Signed-off-by: Nikolay Mitrofanov --- pkg/yaml/validation/error.go | 3 + pkg/yaml/validation/index.go | 107 +++++++++++++ pkg/yaml/validation/index_test.go | 180 ++++++++++++++++++++++ pkg/yaml/validation/schema_loader.go | 2 +- pkg/yaml/validation/schema_loader_test.go | 29 ++++ pkg/yaml/validation/validator.go | 16 +- 6 files changed, 326 insertions(+), 11 deletions(-) create mode 100644 pkg/yaml/validation/index_test.go create mode 100644 pkg/yaml/validation/schema_loader_test.go diff --git a/pkg/yaml/validation/error.go b/pkg/yaml/validation/error.go index 7270c47..1a8450e 100644 --- a/pkg/yaml/validation/error.go +++ b/pkg/yaml/validation/error.go @@ -27,6 +27,7 @@ const ( ErrKindInvalidYAML ErrDocumentValidationFailed ErrSchemaNotFound + ErrRead ) func (k ErrorKind) Error() string { @@ -45,6 +46,8 @@ func (k ErrorKind) String() string { return "DocumentValidationFailed" case ErrSchemaNotFound: return "SchemaNotFound" + case ErrRead: + return "ReadError" default: return "unknown" } diff --git a/pkg/yaml/validation/index.go b/pkg/yaml/validation/index.go index d0f6699..9c02885 100644 --- a/pkg/yaml/validation/index.go +++ b/pkg/yaml/validation/index.go @@ -16,6 +16,14 @@ package validation import ( "fmt" + "io" + "strings" + + "sigs.k8s.io/yaml" +) + +const ( + InvalidGroupPrefix = "invalid:" ) type SchemaIndex struct { @@ -23,6 +31,50 @@ type SchemaIndex struct { Version string `json:"apiVersion"` } +type parseIndexOption struct { + noCheckIsValid bool +} + +type ParseIndexOption func(*parseIndexOption) + +func ParseIndexWithoutCheckValid() ParseIndexOption { + return func(o *parseIndexOption) { + o.noCheckIsValid = true + } +} + +var parseIndexNoCheckValidOpt = ParseIndexWithoutCheckValid() + +// ParseIndex +// parse SchemaIndex from reader +// if reader returns error - wrap reader error with ErrRead +// also function validate is SchemaIndex is valid. Is invalid returns ErrKindValidationFailed +// with pretty error with input doc in error +// if content was not unmarshal wrap unmarshal error with ErrKindValidationFailed ErrKindInvalidYAML +func ParseIndex(reader io.Reader, opts ...ParseIndexOption) (*SchemaIndex, error) { + options := &parseIndexOption{} + for _, o := range opts { + o(options) + } + + content, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrRead, err) + } + + index := SchemaIndex{} + err = yaml.Unmarshal(content, &index) + if err != nil { + return nil, fmt.Errorf("%w %w: schema index unmarshal failed: %w", ErrKindValidationFailed, ErrKindInvalidYAML, err) + } + + if !options.noCheckIsValid && !index.IsValid() { + return nil, index.invalidIndexErr(content) + } + + return &index, nil +} + func (i *SchemaIndex) IsValid() bool { return i.Kind != "" && i.Version != "" } @@ -30,3 +82,58 @@ func (i *SchemaIndex) IsValid() bool { func (i *SchemaIndex) String() string { return fmt.Sprintf("%s, %s", i.Kind, i.Version) } + +// Group +// returns group (deckhouse.io if passed like deckhouse.io/v1) +// if Version is invalid (for example deckhouse.io/dhctl/v1) +// returns invalid: deckhouse.io/dhctl/v1 string +// check is invalid as strings.HasPrefix(s, InvalidGroupPrefix) +// if Version does not contain group (if Version is v1 for example) +// returns empty string +func (i *SchemaIndex) Group() string { + g, _ := i.GroupAndGroupVersion() + return g +} + +// GroupVersion +// returns group version (v1 if passed like deckhouse.io/v1) +// if Version is invalid (for example deckhouse.io/dhctl/v1) +// returns invalid: deckhouse.io/dhctl/v1 string +// check is invalid as strings.HasPrefix(s, InvalidGroupPrefix) +func (i *SchemaIndex) GroupVersion() string { + _, gv := i.GroupAndGroupVersion() + return gv +} + +// GroupAndGroupVersion +// returns group (like deckhouse.io) as first value +// and group version (like v1) as second value +// if Version is invalid (for example deckhouse.io/dhctl/v1) +// returns invalid: deckhouse.io/dhctl/v1 string as all arguments +// check is invalid as strings.HasPrefix(s, InvalidGroupPrefix) +// if Version contains only group version (if Version is v1 for example) +// returns empty string as first value and version as second +func (i *SchemaIndex) GroupAndGroupVersion() (string, string) { + v := i.Version + if v == "" { + return "", "" + } + + switch strings.Count(i.Version, "/") { + case 0: + return "", v + case 1: + i := strings.Index(v, "/") + return v[:i], v[i+1:] + default: + invalid := fmt.Sprintf("%s %s", InvalidGroupPrefix, i.Version) + return invalid, invalid + } +} + +func (i *SchemaIndex) invalidIndexErr(doc []byte) error { + return fmt.Errorf( + "%w: document must contain \"kind\" and \"apiVersion\" fields:\n\tapiVersion: %s\n\tkind: %s\n\n%s", + ErrKindValidationFailed, i.Version, i.Kind, string(doc), + ) +} diff --git a/pkg/yaml/validation/index_test.go b/pkg/yaml/validation/index_test.go new file mode 100644 index 0000000..d2f7d01 --- /dev/null +++ b/pkg/yaml/validation/index_test.go @@ -0,0 +1,180 @@ +// Copyright 2026 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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 validation + +import ( + "fmt" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExtractGroupAndGroupVersion(t *testing.T) { + type testCase struct { + name string + input SchemaIndex + + expectedGroup string + expectedGroupVersion string + } + + assertGroup := func(t *testing.T, test testCase, group string) { + require.Equal(t, test.expectedGroup, group, "should group correct") + } + + assertGroupVersion := func(t *testing.T, test testCase, gv string) { + require.Equal(t, test.expectedGroupVersion, gv, "should group version correct") + } + + tests := []testCase{ + { + name: "empty", + input: SchemaIndex{}, + expectedGroup: "", + expectedGroupVersion: "", + }, + + { + name: "only group version", + input: SchemaIndex{ + Version: "v1", + }, + expectedGroup: "", + expectedGroupVersion: "v1", + }, + + { + name: "group and group version", + input: SchemaIndex{ + Version: "dhctl.deckhouse.io/v1", + }, + expectedGroup: "dhctl.deckhouse.io", + expectedGroupVersion: "v1", + }, + + { + name: "invalid version", + input: SchemaIndex{ + Version: "deckhouse.io/dhctl/v1", + }, + expectedGroup: "invalid: deckhouse.io/dhctl/v1", + expectedGroupVersion: "invalid: deckhouse.io/dhctl/v1", + }, + } + + t.Run("group and group version", func(t *testing.T) { + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + group, groupVersion := test.input.GroupAndGroupVersion() + assertGroup(t, test, group) + assertGroupVersion(t, test, groupVersion) + }) + } + }) + + t.Run("group", func(t *testing.T) { + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + group := test.input.Group() + assertGroup(t, test, group) + }) + } + }) + + t.Run("group version", func(t *testing.T) { + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + gv := test.input.GroupVersion() + assertGroupVersion(t, test, gv) + }) + } + }) +} + +func TestParseIndex(t *testing.T) { + const noIndexDoc = ` +key: key +value: 1 +` + + tests := []struct { + name string + reader io.Reader + errs []error + opts []ParseIndexOption + }{ + { + name: "invalid read", + reader: &errorReader{}, + errs: []error{ErrRead}, + }, + + { + name: "invalid yaml", + reader: strings.NewReader("{invalid"), + errs: []error{ErrKindInvalidYAML, ErrKindValidationFailed}, + }, + + { + name: "without index strict", + reader: strings.NewReader(noIndexDoc), + errs: []error{ErrKindValidationFailed}, + }, + + { + name: "without index no strict", + reader: strings.NewReader(noIndexDoc), + errs: nil, + opts: []ParseIndexOption{ParseIndexWithoutCheckValid()}, + }, + + { + name: "happy case", + reader: strings.NewReader(` +apiVersion: deckhouse.io/v1 +kind: TestKind +sshUser: ubuntu +sshPort: 2200 +`), + errs: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + index, err := ParseIndex(test.reader, test.opts...) + if len(test.errs) == 0 { + require.NoError(t, err, "should not have an error") + return + } + + require.Error(t, err, "should have errors") + + require.Nil(t, index, "should have nil index is invalid") + + for _, expectedErr := range test.errs { + require.ErrorIs(t, err, expectedErr, "should have errored") + } + }) + } +} + +type errorReader struct{} + +func (e errorReader) Read(p []byte) (n int, err error) { + return 0, fmt.Errorf("error") +} diff --git a/pkg/yaml/validation/schema_loader.go b/pkg/yaml/validation/schema_loader.go index e5b7867..c4e3f61 100644 --- a/pkg/yaml/validation/schema_loader.go +++ b/pkg/yaml/validation/schema_loader.go @@ -43,7 +43,7 @@ type SchemaWithIndex struct { func LoadSchemas(reader io.Reader) ([]*SchemaWithIndex, error) { fileContent, err := io.ReadAll(reader) if err != nil { - return nil, err + return nil, fmt.Errorf("%w: %w", ErrRead, err) } openAPISchema := new(OpenAPISchema) diff --git a/pkg/yaml/validation/schema_loader_test.go b/pkg/yaml/validation/schema_loader_test.go new file mode 100644 index 0000000..9de778d --- /dev/null +++ b/pkg/yaml/validation/schema_loader_test.go @@ -0,0 +1,29 @@ +// Copyright 2026 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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 validation + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLoadSchemas(t *testing.T) { + t.Run("return ErrRead if reader returns error", func(t *testing.T) { + _, err := LoadSchemas(errorReader{}) + require.Error(t, err, "reader returns error") + require.ErrorIs(t, err, ErrRead, "reader should returns ErrRead error") + }) +} diff --git a/pkg/yaml/validation/validator.go b/pkg/yaml/validation/validator.go index facc9c9..4b0a309 100644 --- a/pkg/yaml/validation/validator.go +++ b/pkg/yaml/validation/validator.go @@ -15,6 +15,7 @@ package validation import ( + "bytes" "encoding/json" "fmt" "io" @@ -152,15 +153,13 @@ func (v *Validator) Get(index *SchemaIndex) *spec.Schema { } func (v *Validator) Validate(doc *[]byte, opts ...ValidateOption) (*SchemaIndex, error) { - var index SchemaIndex - - err := yaml.Unmarshal(*doc, &index) + // no validate for valid. checking in one place in ValidateWithIndex + index, err := ParseIndex(bytes.NewReader(*doc), parseIndexNoCheckValidOpt) if err != nil { - return nil, fmt.Errorf("%w %w: schema index unmarshal failed: %w", ErrKindValidationFailed, ErrKindInvalidYAML, err) + return nil, err } - err = v.ValidateWithIndex(&index, doc, opts...) - return &index, err + return index, v.ValidateWithIndex(index, doc, opts...) } // ValidateWithIndex @@ -168,10 +167,7 @@ func (v *Validator) Validate(doc *[]byte, opts ...ValidateOption) (*SchemaIndex, // if schema not fount then return ErrSchemaNotFound func (v *Validator) ValidateWithIndex(index *SchemaIndex, doc *[]byte, opts ...ValidateOption) error { if !index.IsValid() { - return fmt.Errorf( - "%w: document must contain \"kind\" and \"apiVersion\" fields:\n\tapiVersion: %s\n\tkind: %s\n\n%s", - ErrKindValidationFailed, index.Version, index.Kind, string(*doc), - ) + return index.invalidIndexErr(*doc) } options := &validateOptions{}