diff --git a/api/v1/composition.go b/api/v1/composition.go index 7175ce64..435f3fc7 100644 --- a/api/v1/composition.go +++ b/api/v1/composition.go @@ -67,8 +67,9 @@ type CompositionStatus struct { } type SimplifiedStatus struct { - Status string `json:"status,omitempty"` - Error string `json:"error,omitempty"` + Status string `json:"status,omitempty"` + Error string `json:"error,omitempty"` + ResolvedSynthName string `json:"resolvedSynthName,omitempty"` } func (s *SimplifiedStatus) String() string { diff --git a/api/v1/config/crd/eno.azure.io_compositions.yaml b/api/v1/config/crd/eno.azure.io_compositions.yaml index 196db13d..d77a4cbb 100644 --- a/api/v1/config/crd/eno.azure.io_compositions.yaml +++ b/api/v1/config/crd/eno.azure.io_compositions.yaml @@ -107,8 +107,6 @@ spec: description: Compositions are synthesized by a Synthesizer, referenced by name. properties: - name: - type: string labelSelector: description: |- A label selector is a label query over a set of resources. The result of matchLabels and @@ -158,6 +156,8 @@ spec: type: object type: object x-kubernetes-map-type: atomic + name: + type: string type: object x-kubernetes-validations: - message: at least one of name or labelSelector must be set @@ -492,6 +492,8 @@ spec: properties: error: type: string + resolvedSynthName: + type: string status: type: string type: object diff --git a/api/v1/config/crd/eno.azure.io_symphonies.yaml b/api/v1/config/crd/eno.azure.io_symphonies.yaml index 15d49ea8..0dedb71a 100644 --- a/api/v1/config/crd/eno.azure.io_symphonies.yaml +++ b/api/v1/config/crd/eno.azure.io_symphonies.yaml @@ -159,8 +159,6 @@ spec: synthesizer: description: Used to populate the composition's spec.synthesizer. properties: - name: - type: string labelSelector: description: |- A label selector is a label query over a set of resources. The result of matchLabels and @@ -210,6 +208,8 @@ spec: type: object type: object x-kubernetes-map-type: atomic + name: + type: string type: object x-kubernetes-validations: - message: at least one of name or labelSelector must be set diff --git a/api/v1/synthesizer.go b/api/v1/synthesizer.go index 04055969..ace8f069 100644 --- a/api/v1/synthesizer.go +++ b/api/v1/synthesizer.go @@ -1,8 +1,13 @@ package v1 import ( + "context" + "errors" + "fmt" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" ) // +kubebuilder:object:root=true @@ -75,3 +80,77 @@ type SynthesizerRef struct { Name string `json:"name,omitempty"` LabelSelector *metav1.LabelSelector `json:"labelSelector,omitempty"` } + +// Sentinel errors for synthesizer resolution. +var ( + // ErrNoMatchingSelector is returned when no synthesizers match the label selector. + ErrNoMatchingSelector = errors.New("no synthesizers match the label selector") + + // ErrMultipleMatches is returned when more than one synthesizer matches the label selector. + ErrMultipleMatches = errors.New("multiple synthesizers match the label selector") +) + +// Resolve resolves a SynthesizerRef to a concrete Synthesizer. +// +// Precedence behavior: When both Name and LabelSelector are set in the ref, +// LabelSelector takes precedence and Name is ignored. This allows for more +// flexible matching when needed while maintaining backwards compatibility +// with name-based resolution. +// +// If the ref has a labelSelector, it lists all synthesizers matching the selector. +// Exactly one synthesizer must match; if zero match, ErrNoMatchingSelector is returned, +// and if more than one match, ErrMultipleMatches is returned. +// +// If labelSelector is not set, it uses the name field to get the synthesizer directly. +// +// Returns: +// - The resolved Synthesizer if found +// - nil, ErrNoMatchingSelector if no synthesizers match the label selector +// - nil, ErrMultipleMatches if more than one synthesizer matches the label selector +// - nil, error if there was an error during resolution +func (r *SynthesizerRef) Resolve(ctx context.Context, c client.Reader) (*Synthesizer, error) { + // LabelSelector takes precedence over name + if r.LabelSelector != nil { + return r.resolveByLabel(ctx, c) + } + + // Fallback to name-based resolution + synth := &Synthesizer{} + synth.Name = r.Name + + return synth, c.Get(ctx, client.ObjectKeyFromObject(synth), synth) +} + +// resolveByLabel resolves a Synthesizer using a label selector. +// It lists all synthesizers matching the selector and returns the matching one. +// Exactly one synthesizer must match the selector. +// +// Returns: +// - The resolved Synthesizer if exactly one matches +// - nil, ErrNoMatchingSelector if no synthesizers match the selector +// - nil, ErrMultipleMatches if more than one synthesizer matches the selector +// - nil, error if there was an error during resolution +func (r *SynthesizerRef) resolveByLabel(ctx context.Context, c client.Reader) (*Synthesizer, error) { + // Convert metav1.LabelSelector to labels.Selector + selector, err := metav1.LabelSelectorAsSelector(r.LabelSelector) + if err != nil { + return nil, fmt.Errorf("converting label selector: %w", err) + } + + // List all synthesizers matching the selector + synthList := &SynthesizerList{} + err = c.List(ctx, synthList, client.MatchingLabelsSelector{Selector: selector}) + if err != nil { + return nil, fmt.Errorf("listing synthesizers by label selector: %w", err) + } + + // Handle results based on match count + switch len(synthList.Items) { + case 0: + return nil, ErrNoMatchingSelector + case 1: + return &synthList.Items[0], nil + default: + return nil, ErrMultipleMatches + } +} diff --git a/api/v1/synthesizer_test.go b/api/v1/synthesizer_test.go new file mode 100644 index 00000000..b8f7e907 --- /dev/null +++ b/api/v1/synthesizer_test.go @@ -0,0 +1,763 @@ +package v1_test + +import ( + "context" + "errors" + "testing" + + apiv1 "github.com/Azure/eno/api/v1" + "github.com/Azure/eno/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +func TestSynthesizerRefResolve(t *testing.T) { + tests := []struct { + name string + ref *apiv1.SynthesizerRef + synthesizers []*apiv1.Synthesizer + expectedSynth string // expected synthesizer name or empty if error expected + expectedErr error + expectedErrMsg string // substring to check in error message + synthNonNil bool // if true, expect synth to be non-nil even on error + }{ + { + name: "empty name returns NotFound from client", + ref: &apiv1.SynthesizerRef{ + Name: "", + }, + synthNonNil: true, + }, + { + name: "name-based resolution success", + ref: &apiv1.SynthesizerRef{ + Name: "test-synth", + }, + synthesizers: []*apiv1.Synthesizer{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-synth", + }, + Spec: apiv1.SynthesizerSpec{ + Image: "test-image:v1", + }, + }, + }, + expectedSynth: "test-synth", + }, + { + name: "name-based resolution - not found error", + ref: &apiv1.SynthesizerRef{ + Name: "non-existent-synth", + }, + synthesizers: []*apiv1.Synthesizer{}, + synthNonNil: true, + }, + { + name: "label selector takes precedence over name", + ref: &apiv1.SynthesizerRef{ + Name: "name-synth", // this should be ignored + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"team": "platform"}, + }, + }, + synthesizers: []*apiv1.Synthesizer{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "name-synth", + Labels: map[string]string{"team": "other"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "label-synth", + Labels: map[string]string{"team": "platform"}, + }, + }, + }, + expectedSynth: "label-synth", // should match by label, not by name + }, + { + name: "label selector - exactly one match success", + ref: &apiv1.SynthesizerRef{ + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "my-app"}, + }, + }, + synthesizers: []*apiv1.Synthesizer{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "synth-1", + Labels: map[string]string{"app": "my-app"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "synth-2", + Labels: map[string]string{"app": "other-app"}, + }, + }, + }, + expectedSynth: "synth-1", + }, + { + name: "label selector - no matches returns ErrNoMatchingSelector", + ref: &apiv1.SynthesizerRef{ + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "non-existent"}, + }, + }, + synthesizers: []*apiv1.Synthesizer{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "synth-1", + Labels: map[string]string{"app": "my-app"}, + }, + }, + }, + expectedErr: apiv1.ErrNoMatchingSelector, + }, + { + name: "label selector - multiple matches returns ErrMultipleMatches", + ref: &apiv1.SynthesizerRef{ + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"team": "platform"}, + }, + }, + synthesizers: []*apiv1.Synthesizer{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "synth-1", + Labels: map[string]string{"team": "platform"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "synth-2", + Labels: map[string]string{"team": "platform"}, + }, + }, + }, + expectedErr: apiv1.ErrMultipleMatches, + }, + { + name: "label selector - invalid selector returns error", + ref: &apiv1.SynthesizerRef{ + LabelSelector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "app", + Operator: metav1.LabelSelectorOperator("InvalidOperator"), + Values: []string{"value"}, + }, + }, + }, + }, + synthesizers: []*apiv1.Synthesizer{}, + expectedErrMsg: "converting label selector", + }, + { + name: "label selector with MatchExpressions - In operator", + ref: &apiv1.SynthesizerRef{ + LabelSelector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "env", + Operator: metav1.LabelSelectorOpIn, + Values: []string{"prod", "staging"}, + }, + }, + }, + }, + synthesizers: []*apiv1.Synthesizer{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "prod-synth", + Labels: map[string]string{"env": "prod"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "dev-synth", + Labels: map[string]string{"env": "dev"}, + }, + }, + }, + expectedSynth: "prod-synth", + }, + { + name: "label selector with MatchExpressions - Exists operator", + ref: &apiv1.SynthesizerRef{ + LabelSelector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "special", + Operator: metav1.LabelSelectorOpExists, + }, + }, + }, + }, + synthesizers: []*apiv1.Synthesizer{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "special-synth", + Labels: map[string]string{"special": "true"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "normal-synth", + Labels: map[string]string{"app": "normal"}, + }, + }, + }, + expectedSynth: "special-synth", + }, + { + name: "label selector with combined MatchLabels and MatchExpressions", + ref: &apiv1.SynthesizerRef{ + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"team": "platform"}, + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "env", + Operator: metav1.LabelSelectorOpIn, + Values: []string{"prod"}, + }, + }, + }, + }, + synthesizers: []*apiv1.Synthesizer{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "platform-prod", + Labels: map[string]string{"team": "platform", "env": "prod"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "platform-dev", + Labels: map[string]string{"team": "platform", "env": "dev"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "other-prod", + Labels: map[string]string{"team": "other", "env": "prod"}, + }, + }, + }, + expectedSynth: "platform-prod", + }, + { + name: "empty label selector matches all - returns ErrMultipleMatches when multiple exist", + ref: &apiv1.SynthesizerRef{ + LabelSelector: &metav1.LabelSelector{}, + }, + synthesizers: []*apiv1.Synthesizer{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "synth-1", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "synth-2", + }, + }, + }, + expectedErr: apiv1.ErrMultipleMatches, + }, + { + name: "empty label selector with single synthesizer - success", + ref: &apiv1.SynthesizerRef{ + LabelSelector: &metav1.LabelSelector{}, + }, + synthesizers: []*apiv1.Synthesizer{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "only-synth", + }, + }, + }, + expectedSynth: "only-synth", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := testutil.NewContext(t) + + // Convert synthesizers to client.Object slice + objs := make([]client.Object, len(tt.synthesizers)) + for i, s := range tt.synthesizers { + objs[i] = s + } + + cli := testutil.NewClient(t, objs...) + + synth, err := tt.ref.Resolve(ctx, cli) + + if tt.expectedErr != nil { + require.Error(t, err) + assert.True(t, errors.Is(err, tt.expectedErr), "expected error %v, got %v", tt.expectedErr, err) + assert.Nil(t, synth) + return + } + + if tt.expectedErrMsg != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErrMsg) + assert.Nil(t, synth) + return + } + + // For name-based cases that return NotFound, synth is non-nil + if tt.synthNonNil { + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound error, got %v", err) + assert.NotNil(t, synth) + return + } + + require.NoError(t, err) + require.NotNil(t, synth) + assert.Equal(t, tt.expectedSynth, synth.Name) + }) + } +} + +func TestSynthesizerRefResolveByName(t *testing.T) { + tests := []struct { + name string + synthName string + synthesizers []*apiv1.Synthesizer + expectedSynth string + expectedErrIs func(error) bool + }{ + { + name: "empty name returns NotFound", + synthName: "", + expectedErrIs: apierrors.IsNotFound, + }, + { + name: "found synthesizer", + synthName: "my-synth", + synthesizers: []*apiv1.Synthesizer{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-synth", + }, + Spec: apiv1.SynthesizerSpec{ + Image: "test:v1", + }, + }, + }, + expectedSynth: "my-synth", + }, + { + name: "not found returns NotFound error", + synthName: "missing-synth", + synthesizers: []*apiv1.Synthesizer{}, + expectedErrIs: apierrors.IsNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := testutil.NewContext(t) + + objs := make([]client.Object, len(tt.synthesizers)) + for i, s := range tt.synthesizers { + objs[i] = s + } + + cli := testutil.NewClient(t, objs...) + + ref := &apiv1.SynthesizerRef{Name: tt.synthName} + synth, err := ref.Resolve(ctx, cli) + + if tt.expectedErrIs != nil { + require.Error(t, err) + // Name-based resolution does not wrap the error, check directly + assert.True(t, tt.expectedErrIs(err), "error check failed for: %v", err) + // Name-based resolution always returns a non-nil synth + assert.NotNil(t, synth) + return + } + + require.NoError(t, err) + require.NotNil(t, synth) + assert.Equal(t, tt.expectedSynth, synth.Name) + }) + } +} + +func TestSynthesizerRefResolveByLabel(t *testing.T) { + tests := []struct { + name string + selector *metav1.LabelSelector + synthesizers []*apiv1.Synthesizer + expectedSynth string + expectedErr error + expectedErrIs func(error) bool + }{ + { + name: "exactly one match", + selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "my-app"}, + }, + synthesizers: []*apiv1.Synthesizer{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "target-synth", + Labels: map[string]string{"app": "my-app"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "other-synth", + Labels: map[string]string{"app": "other"}, + }, + }, + }, + expectedSynth: "target-synth", + }, + { + name: "no matches returns ErrNoMatchingSelector", + selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "nonexistent"}, + }, + synthesizers: []*apiv1.Synthesizer{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "synth", + Labels: map[string]string{"app": "my-app"}, + }, + }, + }, + expectedErr: apiv1.ErrNoMatchingSelector, + }, + { + name: "multiple matches returns ErrMultipleMatches", + selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"team": "infra"}, + }, + synthesizers: []*apiv1.Synthesizer{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "synth-1", + Labels: map[string]string{"team": "infra"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "synth-2", + Labels: map[string]string{"team": "infra"}, + }, + }, + }, + expectedErr: apiv1.ErrMultipleMatches, + }, + { + name: "invalid selector - bad operator", + selector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "key", + Operator: "BadOperator", + }, + }, + }, + synthesizers: []*apiv1.Synthesizer{}, + expectedErrIs: func(err error) bool { return err != nil }, + }, + { + name: "NotIn operator", + selector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "env", + Operator: metav1.LabelSelectorOpNotIn, + Values: []string{"dev", "test"}, + }, + }, + }, + synthesizers: []*apiv1.Synthesizer{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "prod-synth", + Labels: map[string]string{"env": "prod"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "dev-synth", + Labels: map[string]string{"env": "dev"}, + }, + }, + }, + expectedSynth: "prod-synth", + }, + { + name: "DoesNotExist operator", + selector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "deprecated", + Operator: metav1.LabelSelectorOpDoesNotExist, + }, + }, + }, + synthesizers: []*apiv1.Synthesizer{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "deprecated-synth", + Labels: map[string]string{"deprecated": "true"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "current-synth", + Labels: map[string]string{"version": "v2"}, + }, + }, + }, + expectedSynth: "current-synth", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := testutil.NewContext(t) + + objs := make([]client.Object, len(tt.synthesizers)) + for i, s := range tt.synthesizers { + objs[i] = s + } + + cli := testutil.NewClient(t, objs...) + + ref := &apiv1.SynthesizerRef{LabelSelector: tt.selector} + synth, err := ref.Resolve(ctx, cli) + + if tt.expectedErr != nil { + require.Error(t, err) + assert.True(t, errors.Is(err, tt.expectedErr), "expected error %v, got %v", tt.expectedErr, err) + assert.Nil(t, synth) + return + } + + if tt.expectedErrIs != nil { + require.Error(t, err) + assert.True(t, tt.expectedErrIs(err), "error check failed for: %v", err) + assert.Nil(t, synth) + return + } + + require.NoError(t, err) + require.NotNil(t, synth) + assert.Equal(t, tt.expectedSynth, synth.Name) + }) + } +} + +func TestSynthesizerRefResolveClientErrors(t *testing.T) { + t.Run("Get error propagates for name-based resolution", func(t *testing.T) { + ctx := testutil.NewContext(t) + expectedErr := errors.New("simulated get error") + + cli := testutil.NewClientWithInterceptors(t, &interceptor.Funcs{ + Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + return expectedErr + }, + }) + + ref := &apiv1.SynthesizerRef{Name: "test-synth"} + synth, err := ref.Resolve(ctx, cli) + + require.Error(t, err) + assert.True(t, errors.Is(err, expectedErr)) + // Name-based resolution always returns a non-nil synth + assert.NotNil(t, synth) + }) + + t.Run("List error propagates for label-based resolution", func(t *testing.T) { + ctx := testutil.NewContext(t) + expectedErr := errors.New("simulated list error") + + cli := testutil.NewClientWithInterceptors(t, &interceptor.Funcs{ + List: func(ctx context.Context, client client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { + return expectedErr + }, + }) + + ref := &apiv1.SynthesizerRef{ + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}, + }, + } + synth, err := ref.Resolve(ctx, cli) + + require.Error(t, err) + assert.True(t, errors.Is(err, expectedErr)) + assert.Nil(t, synth) + }) + + t.Run("NotFound error for name-based resolution", func(t *testing.T) { + ctx := testutil.NewContext(t) + + cli := testutil.NewClientWithInterceptors(t, &interceptor.Funcs{ + Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + return apierrors.NewNotFound(schema.GroupResource{ + Group: "eno.azure.io", + Resource: "synthesizers", + }, "missing-synth") + }, + }) + + ref := &apiv1.SynthesizerRef{Name: "missing-synth"} + synth, err := ref.Resolve(ctx, cli) + + require.Error(t, err) + // Error is NOT wrapped - check IsNotFound directly + assert.True(t, apierrors.IsNotFound(err)) + // Name-based resolution always returns a non-nil synth + assert.NotNil(t, synth) + }) +} + +func TestSentinelErrors(t *testing.T) { + t.Run("ErrNoMatchingSelector has expected message", func(t *testing.T) { + assert.Equal(t, "no synthesizers match the label selector", apiv1.ErrNoMatchingSelector.Error()) + }) + + t.Run("ErrMultipleMatches has expected message", func(t *testing.T) { + assert.Equal(t, "multiple synthesizers match the label selector", apiv1.ErrMultipleMatches.Error()) + }) + + t.Run("sentinel errors are distinguishable", func(t *testing.T) { + errs := []error{apiv1.ErrNoMatchingSelector, apiv1.ErrMultipleMatches} + for i, err1 := range errs { + for j, err2 := range errs { + if i == j { + assert.True(t, errors.Is(err1, err2)) + } else { + assert.False(t, errors.Is(err1, err2), "expected %v to not be %v", err1, err2) + } + } + } + }) +} + +func TestSynthesizerRefResolveEdgeCases(t *testing.T) { + t.Run("synthesizer with empty labels can be found by name", func(t *testing.T) { + ctx := testutil.NewContext(t) + + synth := &apiv1.Synthesizer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "no-labels-synth", + }, + } + + cli := testutil.NewClient(t, synth) + + ref := &apiv1.SynthesizerRef{Name: "no-labels-synth"} + result, err := ref.Resolve(ctx, cli) + + require.NoError(t, err) + assert.Equal(t, "no-labels-synth", result.Name) + }) + + t.Run("synthesizer spec is preserved in result", func(t *testing.T) { + ctx := testutil.NewContext(t) + + synth := &apiv1.Synthesizer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "full-spec-synth", + }, + Spec: apiv1.SynthesizerSpec{ + Image: "my-image:v1", + Command: []string{"run", "--flag"}, + }, + } + + cli := testutil.NewClient(t, synth) + + ref := &apiv1.SynthesizerRef{Name: "full-spec-synth"} + result, err := ref.Resolve(ctx, cli) + + require.NoError(t, err) + assert.Equal(t, "my-image:v1", result.Spec.Image) + assert.Equal(t, []string{"run", "--flag"}, result.Spec.Command) + }) + + t.Run("label selector with nil MatchLabels and nil MatchExpressions matches all", func(t *testing.T) { + ctx := testutil.NewContext(t) + + synth := &apiv1.Synthesizer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "only-synth", + }, + } + + cli := testutil.NewClient(t, synth) + + ref := &apiv1.SynthesizerRef{ + LabelSelector: &metav1.LabelSelector{ + MatchLabels: nil, + MatchExpressions: nil, + }, + } + result, err := ref.Resolve(ctx, cli) + + require.NoError(t, err) + assert.Equal(t, "only-synth", result.Name) + }) + + t.Run("name with special characters", func(t *testing.T) { + ctx := testutil.NewContext(t) + + // Kubernetes names follow DNS subdomain rules, so test with valid characters + synth := &apiv1.Synthesizer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-synth-v1.2.3", + }, + } + + cli := testutil.NewClient(t, synth) + + ref := &apiv1.SynthesizerRef{Name: "my-synth-v1.2.3"} + result, err := ref.Resolve(ctx, cli) + + require.NoError(t, err) + assert.Equal(t, "my-synth-v1.2.3", result.Name) + }) + + t.Run("context cancellation is respected", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + synth := &apiv1.Synthesizer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-synth", + }, + } + + cli := testutil.NewClient(t, synth) + + ref := &apiv1.SynthesizerRef{Name: "test-synth"} + _, err := ref.Resolve(ctx, cli) + + // The fake client may or may not respect context cancellation, + // but we're testing that the context is passed through + // In a real scenario with network calls, this would fail + // For the fake client, this might succeed + _ = err // Result depends on fake client implementation + }) +} diff --git a/examples/06-selector/example.yaml b/examples/06-selector/example.yaml new file mode 100644 index 00000000..9c670ac7 --- /dev/null +++ b/examples/06-selector/example.yaml @@ -0,0 +1,44 @@ +apiVersion: eno.azure.io/v1 +kind: Synthesizer +metadata: + name: selector-example-v1 + labels: + app: example + tier: frontend + version: v1 +spec: + image: docker.io/ubuntu:latest + command: + - /bin/bash + - -c + - | + echo ' + { + "apiVersion":"config.kubernetes.io/v1", + "kind":"ResourceList", + "items":[ + { + "apiVersion":"v1", + "data":{"selectedBy":"labelSelector", "version":"v1"}, + "kind":"ConfigMap", + "metadata":{ + "name":"selector-config", + "namespace": "default" + } + } + ] + }' +--- + +# This composition selects the v1 synthesizer +apiVersion: eno.azure.io/v1 +kind: Composition +metadata: + name: selector-example + namespace: default +spec: + synthesizer: + labelSelector: + matchLabels: + app: example + version: v1 \ No newline at end of file diff --git a/internal/controllers/composition/controller.go b/internal/controllers/composition/controller.go index 722374aa..4db46a76 100644 --- a/internal/controllers/composition/controller.go +++ b/internal/controllers/composition/controller.go @@ -89,10 +89,7 @@ func (c *compositionController) Reconcile(ctx context.Context, req ctrl.Request) logger.Info("added cleanup finalizer to composition") return ctrl.Result{}, nil } - - synth := &apiv1.Synthesizer{} - synth.Name = comp.Spec.Synthesizer.Name - err = c.client.Get(ctx, client.ObjectKeyFromObject(synth), synth) + synth, err := comp.Spec.Synthesizer.Resolve(ctx, c.client) if errors.IsNotFound(err) { logger.Info(fmt.Sprintf("synthesizer not found for composition[%s], namespace[%s], synthName[%s]", comp.GetName(), comp.GetNamespace(), comp.Spec.Synthesizer.Name)) synth = nil @@ -191,7 +188,7 @@ func (c *compositionController) reconcileSimplifiedStatus(ctx context.Context, s if err := c.client.Status().Patch(ctx, copy, client.MergeFrom(comp)); err != nil { return false, fmt.Errorf("patching simplified status: %w", err) } - logger.Info("sucessfully updated status for composition") + logger.Info("successfully updated status for composition") return true, nil } @@ -207,6 +204,7 @@ func buildSimplifiedStatus(synth *apiv1.Synthesizer, comp *apiv1.Composition) *a status.Status = "MissingSynthesizer" return status } + status.ResolvedSynthName = synth.Name if syn := comp.Status.InFlightSynthesis; syn != nil { for _, result := range syn.Results { diff --git a/internal/controllers/composition/controller_test.go b/internal/controllers/composition/controller_test.go index 6099b654..1cf9bba2 100644 --- a/internal/controllers/composition/controller_test.go +++ b/internal/controllers/composition/controller_test.go @@ -268,3 +268,153 @@ type simplifiedStatusState struct { Synth *apiv1.Synthesizer Comp *apiv1.Composition } + +// TestResolvedSynthNameInSimplifiedStatus proves that buildSimplifiedStatus correctly populates +// ResolvedSynthName from the synthesizer, and leaves it empty when the synthesizer is nil. +func TestResolvedSynthNameInSimplifiedStatus(t *testing.T) { + t.Run("non-nil synth populates ResolvedSynthName", func(t *testing.T) { + synth := &apiv1.Synthesizer{} + synth.Name = "my-synth" + comp := &apiv1.Composition{} + + status := buildSimplifiedStatus(synth, comp) + assert.Equal(t, "my-synth", status.ResolvedSynthName) + }) + + t.Run("nil synth leaves ResolvedSynthName empty", func(t *testing.T) { + comp := &apiv1.Composition{} + + status := buildSimplifiedStatus(nil, comp) + assert.Equal(t, "", status.ResolvedSynthName) + assert.Equal(t, "MissingSynthesizer", status.Status) + }) + + t.Run("deleting composition with nil synth leaves ResolvedSynthName empty", func(t *testing.T) { + comp := &apiv1.Composition{} + comp.DeletionTimestamp = &metav1.Time{} + + status := buildSimplifiedStatus(nil, comp) + assert.Equal(t, "", status.ResolvedSynthName) + assert.Equal(t, "Deleting", status.Status) + }) +} + +// TestLabelSelectorResolution proves that the composition controller correctly resolves +// a synthesizer via label selector and populates the simplified status. +func TestLabelSelectorResolution(t *testing.T) { + synth := &apiv1.Synthesizer{} + synth.Name = "label-synth" + synth.Labels = map[string]string{"team": "platform"} + + comp := &apiv1.Composition{} + comp.Name = "test-comp" + comp.Namespace = "default" + comp.Spec.Synthesizer.LabelSelector = &metav1.LabelSelector{ + MatchLabels: map[string]string{"team": "platform"}, + } + req := reconcile.Request{NamespacedName: client.ObjectKeyFromObject(comp)} + + ctx := testutil.NewContext(t) + cli := testutil.NewClient(t, comp, synth) + c := &compositionController{client: cli} + + // First reconcile adds finalizer + _, err := c.Reconcile(ctx, req) + require.NoError(t, err) + + // Second reconcile resolves synthesizer and updates status + _, err = c.Reconcile(ctx, req) + require.NoError(t, err) + + require.NoError(t, cli.Get(ctx, client.ObjectKeyFromObject(comp), comp)) + require.NotNil(t, comp.Status.Simplified) + assert.Equal(t, "label-synth", comp.Status.Simplified.ResolvedSynthName) +} + +// TestLabelSelectorNoMatch proves that the composition controller returns an error +// when no synthesizer matches the label selector (since ErrNoMatchingSelector is not a NotFound error). +func TestLabelSelectorNoMatch(t *testing.T) { + comp := &apiv1.Composition{} + comp.Name = "test-comp" + comp.Namespace = "default" + comp.Finalizers = []string{"eno.azure.io/cleanup"} + comp.Spec.Synthesizer.LabelSelector = &metav1.LabelSelector{ + MatchLabels: map[string]string{"team": "nonexistent"}, + } + req := reconcile.Request{NamespacedName: client.ObjectKeyFromObject(comp)} + + ctx := testutil.NewContext(t) + cli := testutil.NewClient(t, comp) + c := &compositionController{client: cli} + + _, err := c.Reconcile(ctx, req) + require.Error(t, err) + assert.ErrorIs(t, err, apiv1.ErrNoMatchingSelector) +} + +// TestLabelSelectorMultipleMatches proves that the composition controller returns an error +// when multiple synthesizers match the label selector. +func TestLabelSelectorMultipleMatches(t *testing.T) { + synth1 := &apiv1.Synthesizer{} + synth1.Name = "synth-1" + synth1.Labels = map[string]string{"team": "platform"} + + synth2 := &apiv1.Synthesizer{} + synth2.Name = "synth-2" + synth2.Labels = map[string]string{"team": "platform"} + + comp := &apiv1.Composition{} + comp.Name = "test-comp" + comp.Namespace = "default" + comp.Finalizers = []string{"eno.azure.io/cleanup"} + comp.Spec.Synthesizer.LabelSelector = &metav1.LabelSelector{ + MatchLabels: map[string]string{"team": "platform"}, + } + req := reconcile.Request{NamespacedName: client.ObjectKeyFromObject(comp)} + + ctx := testutil.NewContext(t) + cli := testutil.NewClient(t, comp, synth1, synth2) + c := &compositionController{client: cli} + + _, err := c.Reconcile(ctx, req) + require.Error(t, err) + assert.ErrorIs(t, err, apiv1.ErrMultipleMatches) +} + +// TestLabelSelectorPrecedence proves that when both name and labelSelector are set, +// labelSelector takes precedence. +func TestLabelSelectorPrecedence(t *testing.T) { + nameSynth := &apiv1.Synthesizer{} + nameSynth.Name = "name-synth" + nameSynth.Labels = map[string]string{"team": "other"} + + labelSynth := &apiv1.Synthesizer{} + labelSynth.Name = "label-synth" + labelSynth.Labels = map[string]string{"team": "platform"} + + comp := &apiv1.Composition{} + comp.Name = "test-comp" + comp.Namespace = "default" + comp.Spec.Synthesizer.Name = "name-synth" + comp.Spec.Synthesizer.LabelSelector = &metav1.LabelSelector{ + MatchLabels: map[string]string{"team": "platform"}, + } + req := reconcile.Request{NamespacedName: client.ObjectKeyFromObject(comp)} + + ctx := testutil.NewContext(t) + cli := testutil.NewClient(t, comp, nameSynth, labelSynth) + c := &compositionController{client: cli} + + // Add finalizer + _, err := c.Reconcile(ctx, req) + require.NoError(t, err) + + // Resolve and update status + _, err = c.Reconcile(ctx, req) + require.NoError(t, err) + + require.NoError(t, cli.Get(ctx, client.ObjectKeyFromObject(comp), comp)) + require.NotNil(t, comp.Status.Simplified) + // Should resolve to the label-matched synth, not the name-matched synth + assert.Equal(t, "label-synth", comp.Status.Simplified.ResolvedSynthName) +} diff --git a/internal/controllers/scheduling/controller.go b/internal/controllers/scheduling/controller.go index c58dc61c..1beeca07 100644 --- a/internal/controllers/scheduling/controller.go +++ b/internal/controllers/scheduling/controller.go @@ -117,22 +117,27 @@ func (c *controller) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu var inFlight int var op *op for _, comp := range comps.Items { - comp := comp if comp.Synthesizing() { inFlight++ } + var resolvedSynthName string + if comp.Status.Simplified != nil { + resolvedSynthName = comp.Status.Simplified.ResolvedSynthName + } + if missedReconciliation(&comp, c.watchdogThreshold) { - synth := synthsByName[comp.Spec.Synthesizer.Name] - stuckReconciling.WithLabelValues(comp.Spec.Synthesizer.Name, getSynthOwner(&synth)).Inc() - compositionHealth.WithLabelValues(comp.Name, comp.Namespace, comp.Spec.Synthesizer.Name).Set(1) - logger.Info("detected composition missed reconciliation", "compositionName", comp.Name, "compositionNamespace", comp.Namespace, "synthesizerName", comp.Spec.Synthesizer.Name) + synth := synthsByName[resolvedSynthName] + stuckReconciling.WithLabelValues(resolvedSynthName, getSynthOwner(&synth)).Inc() + compositionHealth.WithLabelValues(comp.Name, comp.Namespace, resolvedSynthName).Set(1) + logger.Info("detected composition missed reconciliation", "compositionName", comp.Name, "compositionNamespace", comp.Namespace, "synthesizerName", resolvedSynthName) } else { - compositionHealth.WithLabelValues(comp.Name, comp.Namespace, comp.Spec.Synthesizer.Name).Set(0) + compositionHealth.WithLabelValues(comp.Name, comp.Namespace, resolvedSynthName).Set(0) } - synth, ok := synthsByName[comp.Spec.Synthesizer.Name] + synth, ok := synthsByName[resolvedSynthName] if !ok { + logger.Info(fmt.Sprintf("synthesizer not found for composition[%s], namespace[%s], synthName[%s]", comp.GetName(), comp.GetNamespace(), resolvedSynthName)) continue } diff --git a/internal/controllers/scheduling/controller_test.go b/internal/controllers/scheduling/controller_test.go index 8eae71b7..909dc930 100644 --- a/internal/controllers/scheduling/controller_test.go +++ b/internal/controllers/scheduling/controller_test.go @@ -443,6 +443,7 @@ func TestSerializationGracePeriod(t *testing.T) { comp.Finalizers = []string{"eno.azure.io/cleanup"} comp.Generation = 2 comp.Spec.Synthesizer.Name = synth.Name + comp.Status.Simplified = &apiv1.SimplifiedStatus{ResolvedSynthName: synth.Name} comp.Status.CurrentSynthesis = &apiv1.Synthesis{UUID: "foo", ObservedCompositionGeneration: 1, Synthesized: ptr.To(metav1.Now())} comp2 := comp.DeepCopy() @@ -495,6 +496,7 @@ func TestDispatchOrder(t *testing.T) { comp.Finalizers = []string{"eno.azure.io/cleanup"} comp.Generation = 2 comp.Spec.Synthesizer.Name = synth.Name + comp.Status.Simplified = &apiv1.SimplifiedStatus{ResolvedSynthName: synth.Name} comp.Status.CurrentSynthesis = &apiv1.Synthesis{ UUID: "foo", ObservedCompositionGeneration: comp.Generation, @@ -568,6 +570,7 @@ func TestSynthOrdering(t *testing.T) { comp.Spec.Synthesizer.Name = synth.Name require.NoError(t, cli.Create(ctx, comp)) + comp.Status.Simplified = &apiv1.SimplifiedStatus{ResolvedSynthName: synth.Name} comp.Status.CurrentSynthesis = &apiv1.Synthesis{UUID: "foo", ObservedCompositionGeneration: comp.Generation, ObservedSynthesizerGeneration: synth.Generation - 1, Synthesized: ptr.To(metav1.Now())} require.NoError(t, cli.Status().Update(ctx, comp)) @@ -736,6 +739,7 @@ func TestCompositionHealthMetrics(t *testing.T) { healthyComp.Spec.Synthesizer.Name = synth.Name require.NoError(t, cli.Create(ctx, healthyComp)) + healthyComp.Status.Simplified = &apiv1.SimplifiedStatus{ResolvedSynthName: synth.Name} healthyComp.Status.CurrentSynthesis = &apiv1.Synthesis{ UUID: "healthy-uuid", Reconciled: ptr.To(metav1.Now()), @@ -750,6 +754,7 @@ func TestCompositionHealthMetrics(t *testing.T) { stuckComp.Spec.Synthesizer.Name = synth.Name require.NoError(t, cli.Create(ctx, stuckComp)) + stuckComp.Status.Simplified = &apiv1.SimplifiedStatus{ResolvedSynthName: synth.Name} stuckComp.Status.CurrentSynthesis = &apiv1.Synthesis{ UUID: "stuck-uuid", Initialized: ptr.To(metav1.NewTime(time.Now().Add(-time.Hour))), // initialized long ago @@ -767,3 +772,106 @@ func TestCompositionHealthMetrics(t *testing.T) { stuckValue := prometheustestutil.ToFloat64(compositionHealth.WithLabelValues("stuck-comp", "default", "test-synth")) assert.Equal(t, float64(1), stuckValue, "stuck composition should have health value 1") } + +// TestNilSimplifiedStatus proves that the controller handles compositions with nil Simplified status +// gracefully, without panicking. +func TestNilSimplifiedStatus(t *testing.T) { + ctx := testutil.NewContext(t) + cli := testutil.NewClient(t) + + c := &controller{client: cli, concurrencyLimit: 10, watchdogThreshold: time.Minute} + + synth := &apiv1.Synthesizer{} + synth.Name = "test-synth" + require.NoError(t, cli.Create(ctx, synth)) + + // Create a composition without Simplified status set (nil) + comp := &apiv1.Composition{} + comp.Name = "no-simplified-comp" + comp.Namespace = "default" + comp.Finalizers = []string{"eno.azure.io/cleanup"} + comp.Spec.Synthesizer.Name = synth.Name + require.NoError(t, cli.Create(ctx, comp)) + + // This should not panic even though comp.Status.Simplified is nil + _, err := c.Reconcile(ctx, ctrl.Request{}) + require.NoError(t, err) +} + +// TestSchedulingUsesResolvedSynthName proves that the scheduling controller uses +// Status.Simplified.ResolvedSynthName to look up synthesizers, not Spec.Synthesizer.Name. +func TestSchedulingUsesResolvedSynthName(t *testing.T) { + ctx := testutil.NewContext(t) + cli := testutil.NewClient(t) + + c := &controller{client: cli, concurrencyLimit: 10} + + synth := &apiv1.Synthesizer{} + synth.Name = "actual-synth" + synth.Generation = 2 + require.NoError(t, cli.Create(ctx, synth)) + + comp := &apiv1.Composition{} + comp.Name = "test-comp" + comp.Namespace = "default" + comp.Finalizers = []string{"eno.azure.io/cleanup"} + comp.Generation = 2 + // Spec.Synthesizer.Name differs from ResolvedSynthName + comp.Spec.Synthesizer.Name = "spec-synth-name" + require.NoError(t, cli.Create(ctx, comp)) + + // Set the resolved synth name to the actual synth + comp.Status.Simplified = &apiv1.SimplifiedStatus{ResolvedSynthName: "actual-synth"} + comp.Status.CurrentSynthesis = &apiv1.Synthesis{ + UUID: "test-uuid", + ObservedCompositionGeneration: 1, // outdated, triggers resynthesis + Synthesized: ptr.To(metav1.Now()), + } + require.NoError(t, cli.Status().Update(ctx, comp)) + + // Reconcile should find the synthesizer via ResolvedSynthName, not Spec.Synthesizer.Name + _, err := c.Reconcile(ctx, ctrl.Request{}) + require.NoError(t, err) + + // The composition should have an in-flight synthesis dispatched + require.NoError(t, cli.Get(ctx, client.ObjectKeyFromObject(comp), comp)) + assert.True(t, comp.Synthesizing(), "composition should be synthesizing since synth was found via ResolvedSynthName") +} + +// TestSchedulingSkipsWhenResolvedSynthNameMissing proves that compositions with +// an empty ResolvedSynthName are skipped by the scheduling controller. +func TestSchedulingSkipsWhenResolvedSynthNameMissing(t *testing.T) { + ctx := testutil.NewContext(t) + cli := testutil.NewClient(t) + + c := &controller{client: cli, concurrencyLimit: 10} + + synth := &apiv1.Synthesizer{} + synth.Name = "test-synth" + require.NoError(t, cli.Create(ctx, synth)) + + comp := &apiv1.Composition{} + comp.Name = "test-comp" + comp.Namespace = "default" + comp.Finalizers = []string{"eno.azure.io/cleanup"} + comp.Generation = 2 + comp.Spec.Synthesizer.Name = synth.Name + require.NoError(t, cli.Create(ctx, comp)) + + // Set Simplified but with empty ResolvedSynthName + comp.Status.Simplified = &apiv1.SimplifiedStatus{ResolvedSynthName: ""} + comp.Status.CurrentSynthesis = &apiv1.Synthesis{ + UUID: "test-uuid", + ObservedCompositionGeneration: 1, + Synthesized: ptr.To(metav1.Now()), + } + require.NoError(t, cli.Status().Update(ctx, comp)) + + _, err := c.Reconcile(ctx, ctrl.Request{}) + require.NoError(t, err) + + // The composition should NOT be synthesizing because the empty ResolvedSynthName + // won't match any synthesizer in the index + require.NoError(t, cli.Get(ctx, client.ObjectKeyFromObject(comp), comp)) + assert.False(t, comp.Synthesizing(), "composition should not be synthesizing when ResolvedSynthName is empty") +} diff --git a/internal/controllers/synthesis/gc.go b/internal/controllers/synthesis/gc.go index 37c939c9..96de6a4b 100644 --- a/internal/controllers/synthesis/gc.go +++ b/internal/controllers/synthesis/gc.go @@ -81,9 +81,7 @@ func (p *podGarbageCollector) Reconcile(ctx context.Context, req ctrl.Request) ( } // GC pods from missing synthesizers - syn := &apiv1.Synthesizer{} - syn.Name = comp.Spec.Synthesizer.Name - err = p.client.Get(ctx, client.ObjectKeyFromObject(syn), syn) + syn, err := comp.Spec.Synthesizer.Resolve(ctx, p.client) logger = logger.WithValues("synthesizerName", syn.Name, "synthesizerGeneration", syn.Generation) if errors.IsNotFound(err) { logger = logger.WithValues("reason", "SynthesizerDeleted") diff --git a/internal/controllers/synthesis/lifecycle.go b/internal/controllers/synthesis/lifecycle.go index d1044884..5baa59b7 100644 --- a/internal/controllers/synthesis/lifecycle.go +++ b/internal/controllers/synthesis/lifecycle.go @@ -105,9 +105,7 @@ func (c *podLifecycleController) Reconcile(ctx context.Context, req ctrl.Request "operationID", comp.GetAzureOperationID(), "operationOrigin", comp.GetAzureOperationOrigin()) ctx = logr.NewContext(ctx, logger) - syn := &apiv1.Synthesizer{} - syn.Name = comp.Spec.Synthesizer.Name - err = c.client.Get(ctx, client.ObjectKeyFromObject(syn), syn) + syn, err := comp.Spec.Synthesizer.Resolve(ctx, c.client) if err != nil { logger.Error(err, "failed to get synthesizer") return ctrl.Result{}, client.IgnoreNotFound(err) diff --git a/internal/controllers/watch/kind.go b/internal/controllers/watch/kind.go index d7ce5152..e11cf299 100644 --- a/internal/controllers/watch/kind.go +++ b/internal/controllers/watch/kind.go @@ -95,12 +95,7 @@ func (k *KindWatchController) newResourceWatchController(parent *WatchController // Watch inputs declared by refs/bindings in synthesizers/compositions err = rrc.Watch(source.Kind(parent.mgr.GetCache(), &apiv1.Composition{}, handler.TypedEnqueueRequestsFromMapFunc(handler.TypedMapFunc[*apiv1.Composition, reconcile.Request](func(ctx context.Context, comp *apiv1.Composition) []reconcile.Request { - if comp.Spec.Synthesizer.Name == "" { - return nil - } - - synth := &apiv1.Synthesizer{} - err = parent.client.Get(ctx, types.NamespacedName{Name: comp.Spec.Synthesizer.Name}, synth) + synth, err := comp.Spec.Synthesizer.Resolve(ctx, parent.client) if err != nil { logr.FromContextOrDiscard(ctx).Error(err, "unable to get synthesizer for composition") return nil diff --git a/internal/controllers/watch/pruning.go b/internal/controllers/watch/pruning.go index e5341dc4..fbef601a 100644 --- a/internal/controllers/watch/pruning.go +++ b/internal/controllers/watch/pruning.go @@ -30,9 +30,7 @@ func (c *pruningController) Reconcile(ctx context.Context, req ctrl.Request) (ct "operationID", comp.GetAzureOperationID(), "operationOrigin", comp.GetAzureOperationOrigin()) ctx = logr.NewContext(ctx, logger) - synth := &apiv1.Synthesizer{} - synth.Name = comp.Spec.Synthesizer.Name - err = c.client.Get(ctx, client.ObjectKeyFromObject(synth), synth) + synth, err := comp.Spec.Synthesizer.Resolve(ctx, c.client) if client.IgnoreNotFound(err) != nil { logger.Error(err, "failed to get synthesizer") return ctrl.Result{}, err diff --git a/internal/execution/executor.go b/internal/execution/executor.go index ae1b504a..81891215 100644 --- a/internal/execution/executor.go +++ b/internal/execution/executor.go @@ -46,15 +46,13 @@ func (e *Executor) Synthesize(ctx context.Context, env *Env) error { return fmt.Errorf("fetching composition: %w", err) } - syn := &apiv1.Synthesizer{} - syn.Name = comp.Spec.Synthesizer.Name - logger = logger.WithValues("synthesizerName", syn.Name) - ctx = logr.NewContext(ctx, logger) - err = e.Reader.Get(ctx, client.ObjectKeyFromObject(syn), syn) + syn, err := comp.Spec.Synthesizer.Resolve(ctx, e.Reader) if err != nil { logger.Error(err, "unable to fetch synthesizer") return fmt.Errorf("fetching synthesizer: %w", err) } + logger = logger.WithValues("synthesizerName", syn.Name) + ctx = logr.NewContext(ctx, logger) logger = logger.WithValues("compositionName", comp.Name, "compositionNamespace", comp.Namespace, "synthesizerName", syn.Name, "operationID", comp.GetAzureOperationID(), "operationOrigin", comp.GetAzureOperationOrigin())