diff --git a/Makefile b/Makefile index 5377a47..c486748 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ # Image URL to use all building/pushing image targets -IMG ?= ghcr.io/anza-labs/scribe:main -PLATFORM ?= linux/$(shell go env GOARCH) +REPOSITORY ?= localhost:5005 +TAG ?= dev-$(shell git describe --match='' --always --abbrev=6 --dirty) +IMG ?= $(REPOSITORY)/scribe:$(TAG) +PLATFORM ?= linux/$(shell go env GOARCH) CHAINSAW_ARGS ?= # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) @@ -108,11 +110,11 @@ endif .PHONY: cluster cluster: kind ctlptl - $(CTLPTL) apply -f hack/kind.yaml + @PATH=${LOCALBIN}:$(PATH) $(CTLPTL) apply -f hack/kind.yaml .PHONY: cluster-reset cluster-reset: kind ctlptl - $(CTLPTL) delete -f hack/kind.yaml + @PATH=${LOCALBIN}:$(PATH) $(CTLPTL) delete -f hack/kind.yaml .PHONY: deploy deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. @@ -131,21 +133,21 @@ $(LOCALBIN): mkdir -p $(LOCALBIN) ## Tool Binaries -KUBECTL ?= kubectl -CHAINSAW ?= $(LOCALBIN)/chainsaw +KUBECTL ?= kubectl +CHAINSAW ?= $(LOCALBIN)/chainsaw CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen -CTLPTL ?= $(LOCALBIN)/ctlptl -KIND ?= $(LOCALBIN)/kind -KUSTOMIZE ?= $(LOCALBIN)/kustomize -GOLANGCI_LINT ?= $(LOCALBIN)/golangci-lint +CTLPTL ?= $(LOCALBIN)/ctlptl +KIND ?= $(LOCALBIN)/kind +KUSTOMIZE ?= $(LOCALBIN)/kustomize +GOLANGCI_LINT ?= $(LOCALBIN)/golangci-lint ## Tool Versions -CHAINSAW_VERSION ?= $(shell grep 'github.com/kyverno/chainsaw ' ./go.mod | cut -d ' ' -f 2) -CONTROLLER_TOOLS_VERSION ?= $(shell grep 'sigs.k8s.io/controller-tools ' ./go.mod | cut -d ' ' -f 2) -CTLPTL_VERSION ?= $(shell grep 'github.com/tilt-dev/ctlptl ' ./go.mod | cut -d ' ' -f 2) -GOLANGCI_LINT_VERSION ?= $(shell grep 'github.com/golangci/golangci-lint ' ./go.mod | cut -d ' ' -f 2) -KIND_VERSION ?= $(shell grep 'sigs.k8s.io/kind ' ./go.mod | cut -d ' ' -f 2) -KUSTOMIZE_VERSION ?= $(shell grep 'sigs.k8s.io/kustomize/kustomize/v5 ' ./go.mod | cut -d ' ' -f 2) +CHAINSAW_VERSION ?= $(shell grep 'github.com/kyverno/chainsaw ' ./go.mod | cut -d ' ' -f 2) +CONTROLLER_TOOLS_VERSION ?= $(shell grep 'sigs.k8s.io/controller-tools ' ./go.mod | cut -d ' ' -f 2) +CTLPTL_VERSION ?= $(shell grep 'github.com/tilt-dev/ctlptl ' ./go.mod | cut -d ' ' -f 2) +GOLANGCI_LINT_VERSION ?= $(shell grep 'github.com/golangci/golangci-lint ' ./go.mod | cut -d ' ' -f 2) +KIND_VERSION ?= $(shell grep 'sigs.k8s.io/kind ' ./go.mod | cut -d ' ' -f 2) +KUSTOMIZE_VERSION ?= $(shell grep 'sigs.k8s.io/kustomize/kustomize/v5 ' ./go.mod | cut -d ' ' -f 2) .PHONY: chainsaw chainsaw: $(CHAINSAW)-$(CHAINSAW_VERSION) ## Download chainsaw locally if necessary. diff --git a/README.md b/README.md index 8988571..a629ace 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,21 @@ metadata: This ensures all resources in the `reloader-example` namespace will inherit the specified annotation, allowing for seamless automation and consistency across deployments. +In addition to propagating static annotations, Scribe also supports annotation templating. This allows for more dynamic and flexible annotations, where values are injected based on the metadata of the observed resources. + +For example, you could set a template annotation that injects the resource's name into the annotation, like so: + +```yaml +--- +apiVersion: v1 +kind: Namespace +metadata: + name: reloader-example + annotations: + scribe.anza-labs.dev/annotations: | + object.name={{ .metadata.name }} +``` + ## Installation [![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/anza-labs)](https://artifacthub.io/packages/search?repo=anza-labs) diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml index 6ff9134..e4fc817 100644 --- a/config/manager/kustomization.yaml +++ b/config/manager/kustomization.yaml @@ -7,5 +7,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization images: - name: controller - newName: localhost:5005/manager - newTag: e2e + newName: localhost:5005/scribe + newTag: dev-892180-dirty diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 3389b71..e04566f 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -52,6 +52,7 @@ spec: - command: - /manager args: + - --v=4 - --leader-elect - --health-probe-bind-address=:8081 - --config-path=/etc/scribe/config.yaml diff --git a/internal/controller/namespacescope.go b/internal/controller/namespacescope.go index ff8bbb7..5a39cbf 100644 --- a/internal/controller/namespacescope.go +++ b/internal/controller/namespacescope.go @@ -17,11 +17,14 @@ limitations under the License. package controller import ( + "bytes" "context" + "errors" "fmt" "maps" "slices" "strings" + "text/template" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" @@ -38,20 +41,36 @@ const ( lastAppliedAnnotations = "scribe.anza-labs.dev/last-applied-annotations" ) +var ErrSkipReconciliation = errors.New("skip reconciliation") + // lister is an interface that defines the listObjects method which returns a list of namespaced names. -type lister interface { +type getLister interface { + Get(context.Context, client.ObjectKey, client.Object, ...client.GetOption) error listObjects(context.Context, string) ([]types.NamespacedName, error) } // mapFunc returns a function that triggers a reconcile request based on the provided lister. // It logs the namespace details and returns reconcile requests for each object in the namespace. -func mapFunc(l lister) func(ctx context.Context, obj client.Object) []reconcile.Request { +func mapFunc(l getLister) func(ctx context.Context, obj client.Object) []reconcile.Request { return func(ctx context.Context, obj client.Object) []reconcile.Request { + ns := &corev1.Namespace{} + log := log.FromContext(ctx, - "group_version_kind", (&corev1.Namespace{}).GroupVersionKind(), + "group_version_kind", ns.GroupVersionKind(), "namespaced_name", klog.KObj(obj), ) + err := l.Get(ctx, types.NamespacedName{Namespace: obj.GetNamespace(), Name: obj.GetName()}, ns) + if err != nil { + log.V(0).Error(err, "Unable to get namespace to trigger reconcile") + return nil + } + + if _, ok := ns.Annotations[annotations]; !ok { + log.V(3).Info("Skipping unmanaged namespace") + return nil + } + namespace := obj.GetName() nns, err := l.listObjects(ctx, namespace) @@ -93,6 +112,7 @@ func NewNamespaceScope(c client.Client, ns string) *NamespaceScope { func (ss *NamespaceScope) UpdateAnnotations( ctx context.Context, objAnnotations map[string]string, + object map[string]any, ) (map[string]string, error) { ns := &corev1.Namespace{} @@ -100,9 +120,23 @@ func (ss *NamespaceScope) UpdateAnnotations( return nil, fmt.Errorf("unable to get namespace: %w", err) } + tpl, err := template.New("").Parse(ns.Annotations[annotations]) + if err != nil { + return nil, fmt.Errorf("failed to parse template: %w", err) + } + + buf := new(bytes.Buffer) + err = tpl.Execute(buf, object) + if err != nil { + return nil, fmt.Errorf("failed to execute template: %w", err) + } + // Retrieve expected and last-applied annotations - expected := unmarshalAnnotations(ns.Annotations[annotations]) + expected := unmarshalAnnotations(buf.String()) lastApplied := unmarshalAnnotations(objAnnotations[lastAppliedAnnotations]) + if len(expected) == 0 && len(lastApplied) == 0 { + return nil, ErrSkipReconciliation + } // Calculate the resulting annotations results := make(map[string]string) @@ -123,6 +157,7 @@ func (ss *NamespaceScope) UpdateAnnotations( final := make(map[string]string) maps.Copy(final, results) delete(results, lastAppliedAnnotations) + final[lastAppliedAnnotations] = marshalAnnotations(results) return final, nil diff --git a/internal/controller/namespacescope_test.go b/internal/controller/namespacescope_test.go index 9ea94a0..652722f 100644 --- a/internal/controller/namespacescope_test.go +++ b/internal/controller/namespacescope_test.go @@ -21,13 +21,16 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" ) func TestUpdateAnnotations(t *testing.T) { @@ -38,13 +41,27 @@ func TestUpdateAnnotations(t *testing.T) { for name, tc := range map[string]struct { // Input parameters - objAnnotations map[string]string + object *corev1.Pod namespaceAnnotations map[string]string // Expected output expectedResult map[string]string + expectedError error }{ + "empty": { + object: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{}, + }, + }, + namespaceAnnotations: map[string]string{}, + expectedError: ErrSkipReconciliation, + }, "add annotations": { - objAnnotations: map[string]string{}, + object: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{}, + }, + }, namespaceAnnotations: map[string]string{ annotations: marshalAnnotations(map[string]string{ "key1": "value1", @@ -57,13 +74,36 @@ func TestUpdateAnnotations(t *testing.T) { }), }, }, - "append annotations": { - objAnnotations: map[string]string{ - "key1": "value1", + "add annotations with template": { + object: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Annotations: map[string]string{}, + }, + }, + namespaceAnnotations: map[string]string{ + annotations: marshalAnnotations(map[string]string{ + "key1": "{{ .metadata.name }}", + }), + }, + expectedResult: map[string]string{ + "key1": "test-pod", lastAppliedAnnotations: marshalAnnotations(map[string]string{ - "key1": "value1", + "key1": "test-pod", }), }, + }, + "append annotations": { + object: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "key1": "value1", + lastAppliedAnnotations: marshalAnnotations(map[string]string{ + "key1": "value1", + }), + }, + }, + }, namespaceAnnotations: map[string]string{ annotations: marshalAnnotations(map[string]string{ "key1": "value1", @@ -80,13 +120,17 @@ func TestUpdateAnnotations(t *testing.T) { }, }, "remove annotations": { - objAnnotations: map[string]string{ - "key1": "value1", - "key2": "value2", - lastAppliedAnnotations: marshalAnnotations(map[string]string{ - "key1": "value1", - "key2": "value2", - }), + object: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: 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{ @@ -101,13 +145,17 @@ func TestUpdateAnnotations(t *testing.T) { }, }, "update annotations": { - objAnnotations: map[string]string{ - "key1": "value1", - "key2": "old-value", - lastAppliedAnnotations: marshalAnnotations(map[string]string{ - "key1": "value1", - "key2": "old-value", - }), + object: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: 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{ @@ -141,9 +189,12 @@ func TestUpdateAnnotations(t *testing.T) { nss := NewNamespaceScope(fakeClient, "test-namespace") - result, err := nss.UpdateAnnotations(context.Background(), tc.objAnnotations) + unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(tc.object) + require.NoError(t, err) - assert.NoError(t, err) + result, err := nss.UpdateAnnotations(context.Background(), tc.object.ObjectMeta.Annotations, unstructuredObj) + + assert.ErrorIs(t, err, tc.expectedError) assert.Equal(t, tc.expectedResult, result) }) } @@ -216,3 +267,83 @@ func TestMarshalAnnotations(t *testing.T) { }) } } + +func TestMapFunc(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + + for name, tc := range map[string]struct { + namespaceAnnotations map[string]string + expectedRequests []reconcile.Request + }{ + "managed namespace with objects": { + namespaceAnnotations: map[string]string{ + annotations: "key=value", + }, + expectedRequests: []reconcile.Request{ + {NamespacedName: types.NamespacedName{Namespace: "test-namespace", Name: "pod1"}}, + {NamespacedName: types.NamespacedName{Namespace: "test-namespace", Name: "pod2"}}, + }, + }, + "unmanaged namespace": { + namespaceAnnotations: map[string]string{}, + expectedRequests: nil, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + // Setup the fake client + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects( + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-namespace", + Namespace: "test-namespace", + Annotations: tc.namespaceAnnotations, + }, + }, + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "test-namespace", + }, + }, + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod2", + Namespace: "test-namespace", + }, + }, + ). + Build() + + // Create a mock lister + lister := &UnstructuredReconciler{ + Client: fakeClient, + Scheme: scheme, + gvk: corev1.SchemeGroupVersion.WithKind("Pod"), + } + + // Create the map function + mapFn := mapFunc(lister) + + // Create a test object to pass to the map function + testObject := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-namespace", + Namespace: "test-namespace", + }, + } + + // Call the map function + requests := mapFn(context.Background(), testObject) + + // Verify the results + assert.ElementsMatch(t, tc.expectedRequests, requests) + }) + } +} diff --git a/internal/controller/unstructured_controller.go b/internal/controller/unstructured_controller.go index 53c7c98..b793f40 100644 --- a/internal/controller/unstructured_controller.go +++ b/internal/controller/unstructured_controller.go @@ -18,7 +18,9 @@ package controller import ( "context" + "errors" "fmt" + "reflect" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -71,13 +73,24 @@ func (r *UnstructuredReconciler) Reconcile(ctx context.Context, req ctrl.Request nss := NewNamespaceScope(r.Client, req.Namespace) - ann, err := nss.UpdateAnnotations(ctx, u.GetAnnotations()) + ann, err := nss.UpdateAnnotations(ctx, u.GetAnnotations(), u.Object) if err != nil { + if errors.Is(err, ErrSkipReconciliation) { + log.V(2).Info("Ignoring unmanaged object") + return ctrl.Result{}, nil + } + return ctrl.Result{}, fmt.Errorf("failed to update the annotation map: %w", err) } + original := u.DeepCopy() u.SetAnnotations(ann) + if reflect.DeepEqual(original.GetAnnotations(), u.GetAnnotations()) { + log.V(2).Info("Nothing to do, skipping") + return ctrl.Result{}, nil + } + if err := r.Update(ctx, u); err != nil { return ctrl.Result{}, fmt.Errorf("failed to update annotations on object: %w", err) } diff --git a/internal/controller/unstructured_controller_test.go b/internal/controller/unstructured_controller_test.go index b0b429f..49e69e9 100644 --- a/internal/controller/unstructured_controller_test.go +++ b/internal/controller/unstructured_controller_test.go @@ -1 +1,101 @@ package controller + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestListObjects(t *testing.T) { + t.Parallel() + + // Setup the fake Kubernetes client + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + + for name, tc := range map[string]struct { + namespace string + existingPods []unstructured.Unstructured + expectedResult []types.NamespacedName + expectedError error + }{ + "list pods in a namespace": { + namespace: "test-namespace", + existingPods: []unstructured.Unstructured{ + newUnstructuredPod("test-namespace", "pod1"), + newUnstructuredPod("test-namespace", "pod2"), + }, + expectedResult: []types.NamespacedName{ + {Namespace: "test-namespace", Name: "pod1"}, + {Namespace: "test-namespace", Name: "pod2"}, + }, + }, + "no pods in namespace": { + namespace: "test-namespace", + existingPods: []unstructured.Unstructured{}, + expectedResult: []types.NamespacedName{}, + }, + "pods in different namespaces": { + namespace: "test-namespace", + existingPods: []unstructured.Unstructured{ + newUnstructuredPod("test-namespace", "pod1"), + newUnstructuredPod("other-namespace", "pod2"), + }, + expectedResult: []types.NamespacedName{ + {Namespace: "test-namespace", Name: "pod1"}, + }, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + // Add existing pods to the fake client + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(convertUnstructuredToObjects(tc.existingPods)...). + Build() + + // Create the UnstructuredReconciler + reconciler := &UnstructuredReconciler{ + Client: fakeClient, + gvk: corev1.SchemeGroupVersion.WithKind("Pod"), + } + + // Call the listObjects method + result, err := reconciler.listObjects(context.Background(), tc.namespace) + + assert.ErrorIs(t, err, tc.expectedError) + assert.ElementsMatch(t, tc.expectedResult, result) + }) + } +} + +// Helper function to create an Unstructured object of type Pod +func newUnstructuredPod(namespace, name string) unstructured.Unstructured { + pod := unstructured.Unstructured{} + pod.SetAPIVersion("v1") + pod.SetKind("Pod") + pod.SetNamespace(namespace) + pod.SetName(name) + return pod +} + +// Helper function to convert a slice of unstructured.Unstructured to runtime.Object slice +func convertUnstructuredToObjects(items []unstructured.Unstructured) []client.Object { + objects := make([]client.Object, len(items)) + for i, u := range items { + copy := u.DeepCopy() + objects[i] = copy + } + return objects +} diff --git a/test/e2e/chainsaw-test.yaml b/test/e2e/chainsaw-test.yaml index 664ae12..4a72ffc 100644 --- a/test/e2e/chainsaw-test.yaml +++ b/test/e2e/chainsaw-test.yaml @@ -23,7 +23,9 @@ spec: metadata: name: ($namespace) annotations: - scribe.anza-labs.dev/annotations: foo=bar + scribe.anza-labs.dev/annotations: | + foo=bar, + obj.name={{ .metadata.name }} - apply: resource: apiVersion: apps/v1 @@ -49,5 +51,8 @@ spec: metadata: name: test annotations: - scribe.anza-labs.dev/last-applied-annotations: foo=bar + scribe.anza-labs.dev/last-applied-annotations: |- + foo=bar, + obj.name=test foo: bar + obj.name: test