Skip to content

Commit

Permalink
PC-10133: Move Project to separate pkg and add validation POC (#138)
Browse files Browse the repository at this point in the history
* add validation poc

* further refine the validation example

* current progress

* simplify rule logic

* remove v1alpha aliases

* add errors tests

* add rules tests

* add strings tests

* make sure labels testing is deterministic

* invert dependency between project and v1alpha

* facilitate recent suggestions and feedback

* rename object validation to struct

* fix error handling

* change labels validation rule name

* let compiler infer the type

* declare the function once

* fix acccidental typo
  • Loading branch information
nieomylnieja authored Oct 4, 2023
1 parent 6be298d commit a001f6e
Show file tree
Hide file tree
Showing 48 changed files with 1,054 additions and 107 deletions.
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,9 @@ check/vulns:

## Verify if the auto generated code has been committed.
check/generate:
$(call _print_check_step,Checking if generated code matches the provided definitions)
./scripts/check-generate.sh
echo "TODO: Turn the step back once all objects were migrated to separate packages"
# $(call _print_check_step,Checking if generated code matches the provided definitions)
# ./scripts/check-generate.sh

## Validate Renovate configuration.
check/renovate:
Expand Down
38 changes: 38 additions & 0 deletions internal/examples/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package examples

import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"

"github.com/goccy/go-yaml"

"github.com/nobl9/nobl9-go/sdk"
)

// GetOfflineEchoClient creates an offline (local mock server) sdk.Client without auth (DisableOkta option).
// It is used exclusively for running code examples without internet connection or valid Nobl9 credentials.
// The body received by the server is decoded to JSON, converted to YAML and printed to stdout.
func GetOfflineEchoClient() *sdk.Client {
// Offline server:
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
objects, err := sdk.ReadObjectsFromSources(r.Context(), sdk.NewObjectSourceReader(r.Body, ""))
if err != nil {
panic(err)
}
data, err := yaml.Marshal(objects[0])
if err != nil {
panic(err)
}
fmt.Println(string(data))
}))
// Create sdk.Client:
u, _ := url.Parse(srv.URL)
config := &sdk.Config{DisableOkta: true, URL: u}
client, err := sdk.NewClient(config)
if err != nil {
panic(err)
}
return client
}
13 changes: 8 additions & 5 deletions manifest/object.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,20 @@ func FilterByKind[T Object](objects []Object) []T {

// Validate performs validation of all the provided objects.
// It aggregates the results into a single error.
func Validate(objects []Object) error {
errs := make([]string, 0)
func Validate(objects []Object) []error {
errs := make([]error, 0)
for i := range objects {
if err := objects[i].Validate(); err != nil {
errs = append(errs, err.Error())
errs = append(errs, err)
}
}
if len(errs) > 0 {
return errors.New(strings.Join(errs, "\n"))
return errs
}
return validateObjectsUniqueness(objects)
if err := validateObjectsUniqueness(objects); err != nil {
return []error{err}
}
return nil
}

// SetDefaultProject sets the default project for each object only if the object is
Expand Down
30 changes: 16 additions & 14 deletions manifest/object_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,35 +45,37 @@ var expectedUniquenessConstraintMessage string

func TestValidate(t *testing.T) {
t.Run("nil objects slice", func(t *testing.T) {
err := Validate(nil)
assert.NoError(t, err)
errs := Validate(nil)
assert.Empty(t, errs)
})

t.Run("empty objects slice", func(t *testing.T) {
err := Validate([]Object{})
assert.NoError(t, err)
errs := Validate([]Object{})
assert.Empty(t, errs)
})

t.Run("no errors", func(t *testing.T) {
err := Validate([]Object{
errs := Validate([]Object{
customObject{kind: KindProject, name: "default"},
customObject{kind: KindRoleBinding, name: "default"},
})
assert.NoError(t, err)
assert.Empty(t, errs)
})

t.Run("errors", func(t *testing.T) {
err := Validate([]Object{
err1 := errors.New("I failed!")
err2 := errors.New("I failed too!")
errs := Validate([]Object{
customObject{},
customObject{validationError: errors.New("I failed!")},
customObject{validationError: errors.New("I failed too!")},
customObject{validationError: err1},
customObject{validationError: err2},
})
assert.Error(t, err)
assert.EqualError(t, err, "I failed!\nI failed too!")
assert.Len(t, errs, 2)
assert.ElementsMatch(t, []error{err1, err2}, errs)
})

t.Run("uniqueness constraint violated", func(t *testing.T) {
err := Validate([]Object{
errs := Validate([]Object{
customObject{kind: KindProject, name: "sun"},
customObject{kind: KindProject, name: "sun"},
customObject{kind: KindProject, name: "moon"},
Expand Down Expand Up @@ -106,8 +108,8 @@ func TestValidate(t *testing.T) {
kind: KindService, name: "jupiter"},
project: "default"},
})
assert.Error(t, err)
assert.EqualError(t, err, strings.ReplaceAll(expectedUniquenessConstraintMessage, "\n", "; "))
assert.Len(t, errs, 1)
assert.EqualError(t, errs[0], strings.ReplaceAll(expectedUniquenessConstraintMessage, "\n", "; "))
})
}

Expand Down
4 changes: 3 additions & 1 deletion manifest/v1alpha/alert_policy.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package v1alpha

import "github.com/nobl9/nobl9-go/manifest"
import (
"github.com/nobl9/nobl9-go/manifest"
)

//go:generate go run ../../scripts/generate-object-impl.go AlertPolicy

Expand Down
112 changes: 112 additions & 0 deletions manifest/v1alpha/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package v1alpha

import (
"encoding/json"
"fmt"
"strings"

"github.com/pkg/errors"

"github.com/nobl9/nobl9-go/manifest"
"github.com/nobl9/nobl9-go/validation"
)

func NewObjectError(object manifest.Object, errs []error) error {
oErr := &ObjectError{
Object: ObjectMetadata{
Kind: object.GetKind(),
Name: object.GetName(),
},
Errors: errs,
}
if v, ok := object.(ObjectContext); ok {
oErr.Object.Source = v.GetManifestSource()
}
if v, ok := object.(manifest.ProjectScopedObject); ok {
oErr.Object.IsProjectScoped = true
oErr.Object.Project = v.GetProject()
}
return oErr
}

type ObjectError struct {
Object ObjectMetadata `json:"object"`
Errors []error `json:"errors"`
}

type ObjectMetadata struct {
Kind manifest.Kind `json:"kind"`
Name string `json:"name"`
Source string `json:"source"`
IsProjectScoped bool `json:"isProjectScoped"`
Project string `json:"project,omitempty"`
}

func (o *ObjectError) Error() string {
b := new(strings.Builder)
b.WriteString(fmt.Sprintf("Validation for %s '%s'", o.Object.Kind, o.Object.Name))
if o.Object.IsProjectScoped {
b.WriteString(" in project '" + o.Object.Project + "'")
}
b.WriteString(" has failed for the following fields:\n")
validation.JoinErrors(b, o.Errors, strings.Repeat(" ", 2))
if o.Object.Source != "" {
b.WriteString("\nManifest source: ")
b.WriteString(o.Object.Source)
}
return b.String()
}

func (o *ObjectError) MarshalJSON() ([]byte, error) {
var errs []json.RawMessage
for _, oErr := range o.Errors {
switch v := oErr.(type) {
case validation.FieldError, *validation.FieldError:
data, err := json.Marshal(v)
if err != nil {
return nil, err
}
errs = append(errs, data)
default:
data, err := json.Marshal(oErr.Error())
if err != nil {
return nil, err
}
errs = append(errs, data)
}
}
return json.Marshal(struct {
Object ObjectMetadata `json:"object"`
Errors []json.RawMessage `json:"errors"`
}{
Object: o.Object,
Errors: errs,
})
}

func (o *ObjectError) UnmarshalJSON(bytes []byte) error {
var intermediate struct {
Object ObjectMetadata `json:"object"`
Errors []json.RawMessage `json:"errors"`
}
if err := json.Unmarshal(bytes, &intermediate); err != nil {
return err
}
o.Object = intermediate.Object
for _, rawErr := range intermediate.Errors {
if len(rawErr) > 0 && rawErr[0] == '{' {
var fErr validation.FieldError
if err := json.Unmarshal(rawErr, &fErr); err != nil {
return err
}
o.Errors = append(o.Errors, fErr)
} else {
var stringErr string
if err := json.Unmarshal(rawErr, &stringErr); err != nil {
return err
}
o.Errors = append(o.Errors, errors.New(stringErr))
}
}
return nil
}
101 changes: 101 additions & 0 deletions manifest/v1alpha/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package v1alpha

import (
"embed"
"encoding/json"
"path/filepath"
"testing"

"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/nobl9/nobl9-go/manifest"
"github.com/nobl9/nobl9-go/validation"
)

//go:embed test_data/errors
var errorsTestData embed.FS

func TestObjectError(t *testing.T) {
errs := []error{
validation.FieldError{
FieldPath: "metadata.name",
FieldValue: "default",
Errors: []string{"here's an error"},
},
validation.FieldError{
FieldPath: "spec.description",
FieldValue: "some long description",
Errors: []string{"here's another error"},
},
}

t.Run("non project scoped object", func(t *testing.T) {
err := &ObjectError{
Object: ObjectMetadata{
Kind: manifest.KindProject,
Name: "default",
Source: "/home/me/project.yaml",
},
Errors: errs,
}
assert.EqualError(t, err, expectedErrorOutput(t, "object_error.txt"))
})

t.Run("project scoped object", func(t *testing.T) {
err := &ObjectError{
Object: ObjectMetadata{
IsProjectScoped: true,
Kind: manifest.KindService,
Name: "my-service",
Project: "default",
Source: "/home/me/service.yaml",
},
Errors: errs,
}
assert.EqualError(t, err, expectedErrorOutput(t, "object_error_project_scoped.txt"))
})
}

func TestObjectError_UnmarshalJSON(t *testing.T) {
expected := &ObjectError{
Object: ObjectMetadata{
Kind: manifest.KindService,
Name: "test-service",
Source: "/home/me/service.yaml",
IsProjectScoped: true,
Project: "default",
},
Errors: []error{
validation.FieldError{
FieldPath: "metadata.project",
FieldValue: "default",
Errors: []string{"nested"},
},
errors.New("some error"),
&validation.FieldError{
FieldPath: "metadata.name",
FieldValue: "my-project",
},
},
}
data, err := json.Marshal(expected)
require.NoError(t, err)

var actual ObjectError
err = json.Unmarshal(data, &actual)
require.NoError(t, err)

assert.Equal(t, expected.Object, actual.Object)
assert.Equal(t, expected.Errors[0], actual.Errors[0])
assert.Equal(t, expected.Errors[1].Error(), actual.Errors[1].Error())
assert.Equal(t, *expected.Errors[2].(*validation.FieldError), actual.Errors[2])
}

func expectedErrorOutput(t *testing.T, name string) string {
t.Helper()
data, err := errorsTestData.ReadFile(filepath.Join("test_data", "errors", name))
require.NoError(t, err)
return string(data)
}
Loading

0 comments on commit a001f6e

Please sign in to comment.