Skip to content

Commit

Permalink
feat(controller): add validation (#52)
Browse files Browse the repository at this point in the history
- [x] Changes covered by unit and/or e2e tests;
- [ ] Documentation and examples updated;

## What this PR does / why we need it:
<!--
What code changes are made?
What problem does this PR addresses, or what feature this PR adds?
-->
Adding validation and events recording to scribe.

## Which issue(s) this PR resolves:
<!--
Usage: `Resolves #<issue number>`, or `Resolves <link to the issue>`.
If PR is about `failing-tests`, please post the related tests in a
comment and do not use `Resolves`
-->
N/A

Signed-off-by: Mateusz Urbanek <[email protected]>
  • Loading branch information
shanduur authored Jan 3, 2025
1 parent ac53efc commit fbc6b98
Show file tree
Hide file tree
Showing 9 changed files with 286 additions and 16 deletions.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ COPY internal/ internal/

# Build
ENV CGO_ENABLED=0
RUN xx-go build -a -o manager cmd/main.go && \
RUN xx-go build -trimpath -a -o manager cmd/main.go && \
xx-verify manager

# Use distroless as minimal base image to package the manager binary
Expand Down
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
2 changes: 1 addition & 1 deletion config/manager/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ kind: Kustomization
images:
- name: controller
newName: localhost:5005/scribe
newTag: dev-892180-dirty
newTag: dev-e31ea2
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
38 changes: 38 additions & 0 deletions internal/controller/metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
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"

"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
23 changes: 20 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,30 @@ import (
"fmt"
"reflect"

"github.com/prometheus/client_golang/prometheus"

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 +89,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.Message())
for _, err := range validationErrors.Items {
r.Recorder.Event(u, corev1.EventTypeWarning, AnnotationValidationFailure, err.Message())
}
}

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

Expand Down
123 changes: 123 additions & 0 deletions internal/controller/validation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
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"
}
parts := make([]string, 0, len(ve.Items))
for _, item := range ve.Items {
parts = append(parts, item.Message())
}
return fmt.Sprintf("errors: %s", strings.Join(parts, "; "))
}

func (ve *ValidationErrors) Message() string {
if len(ve.Items) == 0 {
return "No validation errors"
}
parts := make([]string, 0, len(ve.Items))
for _, item := range ve.Items {
parts = append(parts, item.Message())
}
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)
}

// Message formats the ValidationError into a readable string.
func (ve *ValidationError) Message() string {
if len(ve.Errs) == 0 {
return fmt.Sprintf("validation error at key %q with no specific error details", ve.Key)
}
errMessages := make([]string, 0, len(ve.Errs))
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 {
errs := make([]error, 0, len(strs))
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
}
Loading

0 comments on commit fbc6b98

Please sign in to comment.