Skip to content

Commit

Permalink
feat: add validation
Browse files Browse the repository at this point in the history
Signed-off-by: Mateusz Urbanek <[email protected]>
  • Loading branch information
shanduur committed Jan 3, 2025
1 parent ac53efc commit e31ea2b
Show file tree
Hide file tree
Showing 7 changed files with 271 additions and 14 deletions.
5 changes: 3 additions & 2 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,9 @@ func main() {
t := t

if err = (&controller.UnstructuredReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Recorder: mgr.GetEventRecorderFor(t.GroupVersionKind().String()),
}).SetupWithManager(mgr, t.GroupVersionKind()); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "Unstructured")
os.Exit(1)
Expand Down
7 changes: 7 additions & 0 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ kind: ClusterRole
metadata:
name: manager-role
rules:
- apiGroups:
- ""
resources:
- events
verbs:
- create
- patch
- apiGroups:
- ""
resources:
Expand Down
37 changes: 37 additions & 0 deletions internal/controller/metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
Copyright 2025 anza-labs contributors.
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 controller

import (
"github.com/prometheus/client_golang/prometheus"

Check failure on line 20 in internal/controller/metrics.go

View workflow job for this annotation

GitHub Actions / lint

File is not properly formatted (gci)
"sigs.k8s.io/controller-runtime/pkg/metrics"
)

var (
validationErrorsCounter = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "annotation_validation_errors_total",
Help: "Total count of validation errors",
},
[]string{"source_namespace"},
)
)

func init() {
// Register custom metrics with the global prometheus registry
metrics.Registry.MustRegister(validationErrorsCounter)
}
25 changes: 16 additions & 9 deletions internal/controller/namespacescope.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2024 anza-labs contributors.
Copyright 2024-2025 anza-labs contributors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -27,6 +27,8 @@ import (
"text/template"

corev1 "k8s.io/api/core/v1"
apivalidation "k8s.io/apimachinery/pkg/api/validation"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/klog/v2"
"sigs.k8s.io/controller-runtime/pkg/client"
Expand Down Expand Up @@ -93,16 +95,18 @@ func mapFunc(l getLister) func(ctx context.Context, obj client.Object) []reconci
// It contains the client to interact with the Kubernetes API and the namespace name.
type NamespaceScope struct {
client.Client
namespace types.NamespacedName
namespace *corev1.Namespace
}

// NewNamespaceScope creates a new instance of NamespaceScope for the given namespace name.
func NewNamespaceScope(c client.Client, ns string) *NamespaceScope {
return &NamespaceScope{
Client: c,
namespace: types.NamespacedName{
Namespace: ns,
Name: ns,
namespace: &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Namespace: ns,
Name: ns,
},
},
}
}
Expand All @@ -114,13 +118,11 @@ func (ss *NamespaceScope) UpdateAnnotations(
objAnnotations map[string]string,
object map[string]any,
) (map[string]string, error) {
ns := &corev1.Namespace{}

if err := ss.Get(ctx, ss.namespace, ns); err != nil {
if err := ss.Get(ctx, client.ObjectKeyFromObject(ss.namespace), ss.namespace); err != nil {
return nil, fmt.Errorf("unable to get namespace: %w", err)
}

tpl, err := template.New("").Parse(ns.Annotations[annotations])
tpl, err := template.New("").Parse(ss.namespace.Annotations[annotations])
if err != nil {
return nil, fmt.Errorf("failed to parse template: %w", err)
}
Expand Down Expand Up @@ -160,6 +162,11 @@ func (ss *NamespaceScope) UpdateAnnotations(

final[lastAppliedAnnotations] = marshalAnnotations(results)

err = apivalidation.ValidateAnnotationsSize(final)
if err != nil {
return nil, fmt.Errorf("size validation failed: %w", err)
}

return final, nil
}

Expand Down
22 changes: 19 additions & 3 deletions internal/controller/unstructured_controller.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2024 anza-labs contributors.
Copyright 2024-2025 anza-labs contributors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand All @@ -22,24 +22,29 @@ import (
"fmt"
"reflect"

"github.com/prometheus/client_golang/prometheus"

Check failure on line 25 in internal/controller/unstructured_controller.go

View workflow job for this annotation

GitHub Actions / lint

File is not properly formatted (gci)
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"k8s.io/klog/v2"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
)

// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch

// UnstructuredReconciler reconciles a Unstructured object
type UnstructuredReconciler struct {
client.Client
gvk schema.GroupVersionKind
Scheme *runtime.Scheme
gvk schema.GroupVersionKind
Scheme *runtime.Scheme
Recorder record.EventRecorder
}

// Reconcile is part of the main kubernetes reconciliation loop which aims to
Expand Down Expand Up @@ -83,6 +88,17 @@ func (r *UnstructuredReconciler) Reconcile(ctx context.Context, req ctrl.Request
return ctrl.Result{}, fmt.Errorf("failed to update the annotation map: %w", err)
}

ann, validationErrors := ValidateAnnotations(ann)
if validationErrors != nil {
validationErrorsCounter.With(prometheus.Labels{"source_namespace": req.Namespace}).Inc()

log.V(1).Error(validationErrors, "Validation error")
r.Recorder.Event(nss.namespace, corev1.EventTypeWarning, AnnotationValidationFailure, validationErrors.Error())
for _, err := range validationErrors.Items {
r.Recorder.Event(u, corev1.EventTypeWarning, AnnotationValidationFailure, err.Error())
}
}

original := u.DeepCopy()
u.SetAnnotations(ann)

Expand Down
112 changes: 112 additions & 0 deletions internal/controller/validation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
Copyright 2025 anza-labs contributors.
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 controller

import (
"errors"
"fmt"
"maps"
"strings"

"k8s.io/apimachinery/pkg/util/validation"
)

const AnnotationValidationFailure = "AnnotationValidationFailure"

// ValidationErrors represents a collection of validation errors.
type ValidationErrors struct {
Items []*ValidationError
}

// Error formats all validation errors into a single string.
func (ve *ValidationErrors) Error() string {
if len(ve.Items) == 0 {
return "no validation errors"
}
var parts []string

Check failure on line 40 in internal/controller/validation.go

View workflow job for this annotation

GitHub Actions / lint

Consider pre-allocating `parts` (prealloc)
for _, item := range ve.Items {
parts = append(parts, item.Error())
}
return strings.Join(parts, "; ")
}

// ValidationError represents an error associated with a specific key.
type ValidationError struct {
Key string
Errs []error
}

// NewValidationError creates a new ValidationError or appends errors to an existing one.
func NewValidationError(existing *ValidationError, key string, errs ...error) *ValidationError {
if existing == nil {
return &ValidationError{Key: key, Errs: errs}
}
existing.Key = key
existing.Errs = append(existing.Errs, errs...)
return existing
}

// AppendError adds an error to the ValidationError.
func (ve *ValidationError) AppendError(err error) {
ve.Errs = append(ve.Errs, err)
}

// Error formats the ValidationError into a readable string.
func (ve *ValidationError) Error() string {
if len(ve.Errs) == 0 {
return fmt.Sprintf("validation error at key %q with no specific error details", ve.Key)
}
var errMessages []string

Check failure on line 73 in internal/controller/validation.go

View workflow job for this annotation

GitHub Actions / lint

Consider pre-allocating `errMessages` (prealloc)
for _, err := range ve.Errs {
errMessages = append(errMessages, err.Error())
}
return fmt.Sprintf("validation error at key %q: [%s]", ve.Key, strings.Join(errMessages, ", "))
}

func errsFromStrs(strs []string) []error {
var errs []error

Check failure on line 81 in internal/controller/validation.go

View workflow job for this annotation

GitHub Actions / lint

Consider pre-allocating `errs` (prealloc)
for _, s := range strs {
errs = append(errs, errors.New(s))
}

return errs
}

func ValidateAnnotations(annotations map[string]string) (map[string]string, *ValidationErrors) {
result := maps.Clone(annotations)

var validationErrs []*ValidationError
for k := range annotations {
var verr *ValidationError

errStrs := validation.IsQualifiedName(k)
if errStrs != nil {
verr = NewValidationError(verr, k, errsFromStrs(errStrs)...)
}

if verr != nil {
delete(result, k)
validationErrs = append(validationErrs, verr)
}
}

if len(validationErrs) > 0 {
return result, &ValidationErrors{Items: validationErrs}
}

return result, nil
}
77 changes: 77 additions & 0 deletions internal/controller/validation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
Copyright 2025 anza-labs contributors.
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 controller

import (
"io"
"testing"

"github.com/stretchr/testify/assert"
)

func TestValidationErrors(t *testing.T) {
verr1 := &ValidationError{
Key: "test",
Errs: []error{ErrSkipReconciliation},
}
assert.Equal(t, "validation error at key \"test\": [skip reconciliation]", verr1.Error())

verr2 := &ValidationError{
Key: "other",
Errs: []error{io.EOF},
}

verrs := ValidationErrors{Items: []*ValidationError{verr1, verr2}}
assert.Equal(t, "validation error at key \"test\": [skip reconciliation]; validation error at key \"other\": [EOF]", verrs.Error())
}

func TestValidationError(t *testing.T) {
t.Parallel()

for name, tc := range map[string]struct {
verr *ValidationError
key string
inputErrors []error
}{
"success": {
verr: &ValidationError{Key: "test"},
key: "test",
inputErrors: []error{ErrSkipReconciliation},
},
"success - joined error": {
verr: &ValidationError{Key: "test", Errs: []error{io.EOF}},
key: "test",
inputErrors: []error{ErrSkipReconciliation},
},
"success - without input errors": {
key: "test",
},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()

var expectedErrors []error
if tc.verr != nil {
expectedErrors = append(tc.inputErrors, tc.verr.Errs...)
}

valErr := NewValidationError(tc.verr, tc.key, tc.inputErrors...)
assert.Equal(t, valErr.Key, tc.key)
assert.ElementsMatch(t, expectedErrors, valErr.Errs)
})
}
}

0 comments on commit e31ea2b

Please sign in to comment.