diff --git a/pkg/templates/templates.go b/pkg/templates/templates.go index ea556b85..e8d03e3d 100644 --- a/pkg/templates/templates.go +++ b/pkg/templates/templates.go @@ -173,6 +173,8 @@ type TemplateResolver struct { // If caching is disabled, this will act as a temporary cache for objects during the execution of the // ResolveTemplate call. tempCallCache client.ObjectCache + // When a pre-existing DynamicWatcher is used, let the caller fully manage the QueryBatch. + skipBatchManagement bool } type CacheCleanUpFunc func() error @@ -269,6 +271,34 @@ func NewResolverWithCaching( return resolver, channel, err } +// NewResolverWithDynamicWatcher creates a new caching TemplateResolver instance, using the provided dependency-watcher. +// The caller is responsible for managing the given DynamicWatcher, including starting and stopping it. The caller must +// start a query batch on the DynamicWatcher for the "watcher" object before calling ResolveTemplate. +// +// - dynWatcher is an already running DynamicWatcher from kubernetes-dependency-watches. +// +// - config is the Config instance for configuring optional values for template processing. +func NewResolverWithDynamicWatcher(dynWatcher client.DynamicWatcher, config Config) (*TemplateResolver, error) { + if (config.StartDelim != "" && config.StopDelim == "") || (config.StartDelim == "" && config.StopDelim != "") { + return nil, fmt.Errorf("the configurations StartDelim and StopDelim cannot be set independently") + } + + // It's only required to check config.StartDelim since it's invalid to set these independently + if config.StartDelim == "" { + config.StartDelim = defaultStartDelim + config.StopDelim = defaultStopDelim + } + + return &TemplateResolver{ + config: config, + dynamicClient: nil, + kubeConfig: nil, + dynamicWatcher: dynWatcher, + tempCallCache: nil, + skipBatchManagement: true, + }, nil +} + // HasTemplate performs a simple check for the template start delimiter or the "$ocm_encrypted" prefix // (checkForEncrypted must be set to true) to indicate if the input byte slice has a template. If the startDelim // argument is an empty string, the default start delimiter of "{{" will be used. @@ -557,30 +587,32 @@ func (t *TemplateResolver) ResolveTemplate( if t.dynamicWatcher != nil { watcher := *options.Watcher - err := t.dynamicWatcher.StartQueryBatch(watcher) - if err != nil { - if !errors.Is(err, client.ErrQueryBatchInProgress) { - return resolvedResult, err - } + if !t.skipBatchManagement { + err := t.dynamicWatcher.StartQueryBatch(watcher) + if err != nil { + if !errors.Is(err, client.ErrQueryBatchInProgress) { + return resolvedResult, err + } - if !options.DisableAutoCacheCleanUp { - return resolvedResult, fmt.Errorf( - "ResolveTemplate cannot be called with the same watchedObject in parallel: %w", err, - ) + if !options.DisableAutoCacheCleanUp { + return resolvedResult, fmt.Errorf( + "ResolveTemplate cannot be called with the same watchedObject in parallel: %w", err, + ) + } } - } - if options.DisableAutoCacheCleanUp { - resolvedResult.CacheCleanUp = func() error { - return t.dynamicWatcher.EndQueryBatch(*options.Watcher) - } - } else { - defer func() { - err := t.dynamicWatcher.EndQueryBatch(watcher) - if err != nil && !errors.Is(err, client.ErrQueryBatchNotStarted) { - klog.Errorf("failed to end the query batch for %s: %v", watcher, err) + if options.DisableAutoCacheCleanUp { + resolvedResult.CacheCleanUp = func() error { + return t.dynamicWatcher.EndQueryBatch(*options.Watcher) } - }() + } else { + defer func() { + err := t.dynamicWatcher.EndQueryBatch(watcher) + if err != nil && !errors.Is(err, client.ErrQueryBatchNotStarted) { + klog.Errorf("failed to end the query batch for %s: %v", watcher, err) + } + }() + } } for i, contextTransformer := range options.ContextTransformers { diff --git a/pkg/templates/templates_test.go b/pkg/templates/templates_test.go index 3f27b33e..4e8c7328 100644 --- a/pkg/templates/templates_test.go +++ b/pkg/templates/templates_test.go @@ -16,6 +16,7 @@ import ( "github.com/stolostron/kubernetes-dependency-watches/client" yaml "gopkg.in/yaml.v3" "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/reconcile" ) func TestNewResolver(t *testing.T) { @@ -55,6 +56,28 @@ func TestNewResolverWithCaching(t *testing.T) { } } +func TestNewResolverWithDynamicWatcher(t *testing.T) { + t.Parallel() + + dynWatcher, err := client.New(k8sConfig, fakeReconciler{}, &client.Options{EnableCache: true}) + if err != nil { + t.Fatalf("No error was expected: %v", err) + } + + resolver, err := NewResolverWithDynamicWatcher(dynWatcher, Config{}) + if err != nil { + t.Fatalf("No error was expected: %v", err) + } + + if resolver.config.StartDelim != "{{" || resolver.config.StopDelim != "}}" { + t.Fatalf( + "Expected delimiters: {{ and }} got: %s and %s", + resolver.config.StartDelim, + resolver.config.StopDelim, + ) + } +} + func TestNewResolverFailures(t *testing.T) { t.Parallel() @@ -473,6 +496,91 @@ func TestResolveTemplateWithCachingListQuery(t *testing.T) { } } +type fakeReconciler struct{} + +func (r fakeReconciler) Reconcile(_ context.Context, _ client.ObjectIdentifier) (reconcile.Result, error) { + return reconcile.Result{}, nil +} + +func TestResolveTemplateWithPreexistingWatcher(t *testing.T) { + t.Parallel() + + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + + fr := fakeReconciler{} + + dynWatcher, err := client.New(k8sConfig, fr, &client.Options{EnableCache: true}) + if err != nil { + t.Fatalf("No error was expected: %v", err) + } + + resolver, err := NewResolverWithDynamicWatcher(dynWatcher, Config{}) + if err != nil { + t.Fatalf("No error was expected: %v", err) + } + + tmplStr := `data1: '{{ (lookup "v1" "ConfigMap" "testns" "testcm-enva").data ` + + `| mustToRawJson | toLiteral }}'` + + tmplStrBytes, err := yamlToJSON([]byte(tmplStr)) + if err != nil { + t.Fatalf("No error was expected: %v", err) + } + + watcher := client.ObjectIdentifier{ + Version: "v1", + Kind: "ConfigMap", + Namespace: "testns", + Name: "watcher", + } + + _, err = resolver.ResolveTemplate(tmplStrBytes, nil, &ResolveOptions{Watcher: &watcher}) + if err == nil || !strings.Contains(err.Error(), "DynamicWatcher must be started") { + t.Fatalf("Expected error requiring the DynamicWatcher to be started, got: %v", err) + } + + go func() { + _ = dynWatcher.Start(ctx) + }() + + <-dynWatcher.Started() + + err = dynWatcher.StartQueryBatch(watcher) + if err != nil { + t.Fatalf("No error was expected: %v", err) + } + + defer func() { + err := dynWatcher.EndQueryBatch(watcher) + if err != nil { + t.Fatalf("No error was expected: %v", err) + } + }() + + // Before resolving the template, do a get on that object through the DynamicWatcher + // Then the test will validate that there is only one watch. + configMapGVK := schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"} + + _, err = dynWatcher.Get(watcher, configMapGVK, "testns", "testcm-enva") + if err != nil { + t.Fatalf("No error was expected: %v", err) + } + + result, err := resolver.ResolveTemplate(tmplStrBytes, nil, &ResolveOptions{Watcher: &watcher}) + if err != nil { + t.Fatalf("No error was expected: %v", err) + } + + if string(result.ResolvedJSON) != `{"data1":{"cmkey1":"cmkey1Val"}}` { + t.Fatalf("Unexpected template: %s", string(result.ResolvedJSON)) + } + + if resolver.GetWatchCount() != 1 { + t.Fatalf("Expected a watch count of 1 but got: %d", resolver.GetWatchCount()) + } +} + func TestResolveTemplateDefaultConfig(t *testing.T) { t.Parallel()