Skip to content

Commit

Permalink
feat: removed annotations
Browse files Browse the repository at this point in the history
Signed-off-by: Mateusz Urbanek <[email protected]>
  • Loading branch information
shanduur committed Dec 30, 2024
1 parent 68991ff commit 994f075
Show file tree
Hide file tree
Showing 4 changed files with 260 additions and 15 deletions.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
71 changes: 57 additions & 14 deletions internal/controller/namespacescope.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"context"
"fmt"
"maps"
"slices"
"strings"

corev1 "k8s.io/api/core/v1"
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
201 changes: 201 additions & 0 deletions internal/controller/namespacescope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
1 change: 1 addition & 0 deletions test/e2e/chainsaw-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,5 @@ spec:
metadata:
name: test
annotations:
scribe.anza-labs.dev/last-applied-annotations: foo=bar
foo: bar

0 comments on commit 994f075

Please sign in to comment.