Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pkg/yaml/validation/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const (
ErrKindInvalidYAML
ErrDocumentValidationFailed
ErrSchemaNotFound
ErrRead
)

func (k ErrorKind) Error() string {
Expand All @@ -45,6 +46,8 @@ func (k ErrorKind) String() string {
return "DocumentValidationFailed"
case ErrSchemaNotFound:
return "SchemaNotFound"
case ErrRead:
return "ReadError"
default:
return "unknown"
}
Expand Down
107 changes: 107 additions & 0 deletions pkg/yaml/validation/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,124 @@ package validation

import (
"fmt"
"io"
"strings"

"sigs.k8s.io/yaml"
)

const (
InvalidGroupPrefix = "invalid:"
)

type SchemaIndex struct {
Kind string `json:"kind"`
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 != ""
}

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),
)
}
180 changes: 180 additions & 0 deletions pkg/yaml/validation/index_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
2 changes: 1 addition & 1 deletion pkg/yaml/validation/schema_loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions pkg/yaml/validation/schema_loader_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
Loading