From 994f075484cec0c6b106ef64b6dd664b16354029 Mon Sep 17 00:00:00 2001 From: Mateusz Urbanek Date: Mon, 30 Dec 2024 17:11:32 +0100 Subject: [PATCH] feat: removed annotations Signed-off-by: Mateusz Urbanek --- go.mod | 2 +- internal/controller/namespacescope.go | 71 ++++++-- internal/controller/namespacescope_test.go | 201 +++++++++++++++++++++ test/e2e/chainsaw-test.yaml | 1 + 4 files changed, 260 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index c7f09d7..5fe1c0d 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.0 toolchain go1.23.4 require ( + github.com/stretchr/testify v1.10.0 k8s.io/api v0.32.0 k8s.io/apimachinery v0.32.0 k8s.io/client-go v0.32.0 @@ -284,7 +285,6 @@ require ( github.com/stbenjam/no-sprintf-host-port v0.1.1 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/stretchr/testify v1.10.0 // indirect github.com/subosito/gotenv v1.4.1 // indirect github.com/tdakkota/asciicheck v0.2.0 // indirect github.com/tetafro/godot v1.4.18 // indirect diff --git a/internal/controller/namespacescope.go b/internal/controller/namespacescope.go index 3cafb58..ff8bbb7 100644 --- a/internal/controller/namespacescope.go +++ b/internal/controller/namespacescope.go @@ -20,6 +20,7 @@ import ( "context" "fmt" "maps" + "slices" "strings" corev1 "k8s.io/api/core/v1" @@ -33,7 +34,8 @@ import ( // +kubebuilder:rbac:groups=core,resources=namespaces,verbs=get;list;watch const ( - annotationsAnnotation = "scribe.anza-labs.dev/annotations" + annotations = "scribe.anza-labs.dev/annotations" + lastAppliedAnnotations = "scribe.anza-labs.dev/last-applied-annotations" ) // lister is an interface that defines the listObjects method which returns a list of namespaced names. @@ -87,36 +89,49 @@ func NewNamespaceScope(c client.Client, ns string) *NamespaceScope { } // UpdateAnnotations updates the annotations of a namespace. -// It merges the new annotations with existing ones. +// It synchronizes annotations with the new ones, removes missing ones, and tracks last-applied annotations. func (ss *NamespaceScope) UpdateAnnotations( ctx context.Context, - annotations map[string]string, + objAnnotations map[string]string, ) (map[string]string, error) { ns := &corev1.Namespace{} - results := map[string]string{} - maps.Copy(results, annotations) - if err := ss.Get(ctx, ss.namespace, ns); err != nil { return nil, fmt.Errorf("unable to get namespace: %w", err) } - s, ok := ns.Annotations[annotationsAnnotation] - if !ok { - return results, nil - } + // Retrieve expected and last-applied annotations + expected := unmarshalAnnotations(ns.Annotations[annotations]) + lastApplied := unmarshalAnnotations(objAnnotations[lastAppliedAnnotations]) - for k, v := range parseAnnotations(s) { + // Calculate the resulting annotations + results := make(map[string]string) + maps.Copy(results, objAnnotations) // Start with current annotations + + // Add/Update new annotations + for k, v := range expected { results[k] = v } - return results, nil + // Remove annotations that were in last-applied but are missing in newAnnotations + for k := range lastApplied { + if _, exists := expected[k]; !exists { + delete(results, k) + } + } + + final := make(map[string]string) + maps.Copy(final, results) + delete(results, lastAppliedAnnotations) + final[lastAppliedAnnotations] = marshalAnnotations(results) + + return final, nil } -// parseAnnotations parses a string containing key-value pairs into a map. +// unmarshalAnnotations parses a string containing key-value pairs into a map. // The input string should be formatted as comma-separated key=value pairs. // Newline characters are treated as commas for parsing. -func parseAnnotations(input string) map[string]string { +func unmarshalAnnotations(input string) map[string]string { result := make(map[string]string) // Normalize the string by replacing newlines and whitespace followed by commas @@ -143,3 +158,31 @@ func parseAnnotations(input string) map[string]string { return result } + +// marshalAnnotations converts a map into a formatted string of key-value pairs. +// The output string will be formatted as comma-separated key=value pairs, +// with each pair appearing on a new line for readability. The keys are sorted. +func marshalAnnotations(annotations map[string]string) string { + var builder strings.Builder + + // Collect keys into a slice + keys := make([]string, 0, len(annotations)) + for key := range annotations { + keys = append(keys, key) + } + + // Sort the keys + slices.Sort(keys) + + // Iterate over the sorted keys and build the result string + for i, key := range keys { + if i > 0 { + builder.WriteString(",\n") + } + builder.WriteString(key) + builder.WriteString("=") + builder.WriteString(annotations[key]) + } + + return builder.String() +} diff --git a/internal/controller/namespacescope_test.go b/internal/controller/namespacescope_test.go index b53f324..77a4b5a 100644 --- a/internal/controller/namespacescope_test.go +++ b/internal/controller/namespacescope_test.go @@ -15,3 +15,204 @@ limitations under the License. */ package controller + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/stretchr/testify/assert" +) + +func TestUpdateAnnotations(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + + for name, tc := range map[string]struct { + // Input parameters + objAnnotations map[string]string + namespaceAnnotations map[string]string + // Expected output + expectedResult map[string]string + }{ + "add annotations": { + objAnnotations: map[string]string{}, + namespaceAnnotations: map[string]string{ + annotations: marshalAnnotations(map[string]string{ + "key1": "value1", + }), + }, + expectedResult: map[string]string{ + "key1": "value1", + lastAppliedAnnotations: marshalAnnotations(map[string]string{ + "key1": "value1", + }), + }, + }, + "append annotations": { + objAnnotations: map[string]string{ + "key1": "value1", + lastAppliedAnnotations: marshalAnnotations(map[string]string{ + "key1": "value1", + }), + }, + namespaceAnnotations: map[string]string{ + annotations: marshalAnnotations(map[string]string{ + "key1": "value1", + "key2": "value2", + }), + }, + expectedResult: map[string]string{ + "key1": "value1", + "key2": "value2", + lastAppliedAnnotations: marshalAnnotations(map[string]string{ + "key1": "value1", + "key2": "value2", + }), + }, + }, + "remove annotations": { + objAnnotations: map[string]string{ + "key1": "value1", + "key2": "value2", + lastAppliedAnnotations: marshalAnnotations(map[string]string{ + "key1": "value1", + "key2": "value2", + }), + }, + namespaceAnnotations: map[string]string{ + annotations: marshalAnnotations(map[string]string{ + "key1": "value1", + }), + }, + expectedResult: map[string]string{ + "key1": "value1", + lastAppliedAnnotations: marshalAnnotations(map[string]string{ + "key1": "value1", + }), + }, + }, + "update annotations": { + objAnnotations: map[string]string{ + "key1": "value1", + "key2": "old-value", + lastAppliedAnnotations: marshalAnnotations(map[string]string{ + "key1": "value1", + "key2": "old-value", + }), + }, + namespaceAnnotations: map[string]string{ + annotations: marshalAnnotations(map[string]string{ + "key1": "value1", + "key2": "new-value", // Updated value for key2 + }), + }, + expectedResult: map[string]string{ + "key1": "value1", + "key2": "new-value", // key2 updated to new value + lastAppliedAnnotations: marshalAnnotations(map[string]string{ + "key1": "value1", + "key2": "new-value", // Updated value in lastAppliedAnnotations + }), + }, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-namespace", + Namespace: "test-namespace", + Annotations: tc.namespaceAnnotations, + }, + }). + Build() + + nss := NewNamespaceScope(fakeClient, "test-namespace") + + result, err := nss.UpdateAnnotations(context.Background(), tc.objAnnotations) + + assert.NoError(t, err) + assert.Equal(t, tc.expectedResult, result) + }) + } +} + +func TestUnmarshalAnnotations(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + input string + expected map[string]string + }{ + "simple_case": { + input: "key1=value1,key2=value2", + expected: map[string]string{"key1": "value1", "key2": "value2"}, + }, + "with_newlines": { + input: "key1=value1,\nkey2=value2\nkey3=value3", + expected: map[string]string{"key1": "value1", "key2": "value2", "key3": "value3"}, + }, + "with_whitespace": { + input: " key1 = value1 , key2=value2 ", + expected: map[string]string{"key1": "value1", "key2": "value2"}, + }, + "empty_string": { + input: "", + expected: map[string]string{}, + }, + "invalid_pairs": { + input: "key1=value1,key2,key3=value3", + expected: map[string]string{"key1": "value1", "key3": "value3"}, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + result := unmarshalAnnotations(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestMarshalAnnotations(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + input map[string]string + expected string + }{ + "simple_case": { + input: map[string]string{"key1": "value1", "key2": "value2"}, + expected: "key1=value1,\nkey2=value2", + }, + "single_pair": { + input: map[string]string{"key1": "value1"}, + expected: "key1=value1", + }, + "empty_map": { + input: map[string]string{}, + expected: "", + }, + "with_special_chars": { + input: map[string]string{"key1": "value1", "key_2": "value=2"}, + expected: "key1=value1,\nkey_2=value=2", + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + result := marshalAnnotations(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/test/e2e/chainsaw-test.yaml b/test/e2e/chainsaw-test.yaml index b8bc5b8..664ae12 100644 --- a/test/e2e/chainsaw-test.yaml +++ b/test/e2e/chainsaw-test.yaml @@ -49,4 +49,5 @@ spec: metadata: name: test annotations: + scribe.anza-labs.dev/last-applied-annotations: foo=bar foo: bar