diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml new file mode 100644 index 00000000..29f56237 --- /dev/null +++ b/.github/workflows/e2e.yaml @@ -0,0 +1,73 @@ +name: E2E Tests +on: + push: + pull_request: + branches: + - main + workflow_dispatch: + schedule: + - cron: "0 0 * * *" + +jobs: + e2e: + name: E2E (${{ matrix.config.name }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + + strategy: + fail-fast: false + matrix: + config: + - name: Default + disable_ssa: "false" + - name: Disable SSA + disable_ssa: "true" + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Create Kind cluster + uses: helm/kind-action@v1 + + - name: Wait for apiserver + run: | + kind export kubeconfig --name chart-testing + while ! kubectl api-resources; do sleep 1; done + + - name: Build and deploy Eno + env: + REGISTRY: localhost + SKIP_PUSH: "yes" + DISABLE_SSA: "${{ matrix.config.disable_ssa }}" + run: ./dev/build.sh + + - name: Load images into Kind + run: | + for image in $(docker images --format "{{.Repository}}:{{.Tag}}" | grep localhost); do + kind load docker-image --name chart-testing $image + done + + - name: Wait for Eno rollout + timeout-minutes: 3 + run: | + kubectl rollout status deployment/eno-controller --timeout=120s + kubectl rollout status deployment/eno-reconciler --timeout=120s + + - name: Run E2E tests + timeout-minutes: 10 + run: make test-e2e + + - name: Dump diagnostics + if: failure() + run: | + echo "=== Controller Logs ===" && kubectl logs -l app=eno-controller --tail=200 || true + echo "=== Reconciler Logs ===" && kubectl logs -l app=eno-reconciler --tail=200 || true + echo "=== Events ===" && kubectl get events --sort-by=.lastTimestamp || true + echo "=== Compositions ===" && kubectl get compositions -A -o yaml || true + echo "=== Synthesizers ===" && kubectl get synthesizers -o yaml || true + echo "=== ResourceSlices ===" && kubectl get resourceslices -A -o yaml || true + echo "=== Pods ===" && kubectl get pods -A -o wide || true diff --git a/.github/workflows/unit.yaml b/.github/workflows/unit.yaml index 1d522d6e..a007da17 100644 --- a/.github/workflows/unit.yaml +++ b/.github/workflows/unit.yaml @@ -23,5 +23,5 @@ jobs: run: echo "KUBEBUILDER_ASSETS=$(./hack/download-k8s.sh)" >> $GITHUB_ENV - name: Run tests - run: go test -v ./... + run: make test diff --git a/Makefile b/Makefile index 7ef671c8..b7c45df4 100644 --- a/Makefile +++ b/Makefile @@ -26,3 +26,11 @@ docker-build-eno-reconciler: setup-testenv: @echo "Installing controller-runtime testenv binaries..." @go run sigs.k8s.io/controller-runtime/tools/setup-envtest@latest use -p path + +.PHONY: test +test: + go test -v $$(go list ./... | grep -v '/e2e') + +.PHONY: test-e2e +test-e2e: + go test -v -timeout 10m -count=1 ./e2e diff --git a/api/v1/composition.go b/api/v1/composition.go index 7175ce64..daaa4502 100644 --- a/api/v1/composition.go +++ b/api/v1/composition.go @@ -1,6 +1,8 @@ package v1 import ( + "context" + "errors" "fmt" "strconv" @@ -58,6 +60,82 @@ type CompositionSpec struct { SynthesisEnv []EnvVar `json:"synthesisEnv,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") +) + +// ResolveSynthesizer resolves the Composition's 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 (c *Composition) ResolveSynthesizer(ctx context.Context, cl client.Reader) (*Synthesizer, error) { + ref := &c.Spec.Synthesizer + // LabelSelector takes precedence over name + if ref.LabelSelector != nil { + return c.resolveSynthesizerByLabel(ctx, cl) + } + + // Fallback to name-based resolution + synth := &Synthesizer{} + synth.Name = ref.Name + + return synth, cl.Get(ctx, client.ObjectKeyFromObject(synth), synth) +} + +// resolveSynthesizerByLabel 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 (c *Composition) resolveSynthesizerByLabel(ctx context.Context, cl client.Reader) (*Synthesizer, error) { + ref := &c.Spec.Synthesizer + // Convert metav1.LabelSelector to labels.Selector + selector, err := metav1.LabelSelectorAsSelector(ref.LabelSelector) + if err != nil { + return nil, fmt.Errorf("converting label selector: %w", err) + } + + // List all synthesizers matching the selector + synthList := &SynthesizerList{} + err = cl.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 + } +} + type CompositionStatus struct { Simplified *SimplifiedStatus `json:"simplified,omitempty"` InFlightSynthesis *Synthesis `json:"inFlightSynthesis,omitempty"` @@ -67,8 +145,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_test.go b/api/v1/synthesizer_test.go new file mode 100644 index 00000000..e9e16170 --- /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 + comp *apiv1.Composition + 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", + comp: &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: apiv1.SynthesizerRef{ + Name: "", + }}}, + synthNonNil: true, + }, + { + name: "name-based resolution success", + comp: &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: 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", + comp: &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: apiv1.SynthesizerRef{ + Name: "non-existent-synth", + }}}, + synthesizers: []*apiv1.Synthesizer{}, + synthNonNil: true, + }, + { + name: "label selector takes precedence over name", + comp: &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: 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", + comp: &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: 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", + comp: &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: 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", + comp: &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: 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", + comp: &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: 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", + comp: &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: 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", + comp: &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: 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", + comp: &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: 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", + comp: &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: 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", + comp: &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: 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.comp.ResolveSynthesizer(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...) + + comp := &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: apiv1.SynthesizerRef{Name: tt.synthName}}} + synth, err := comp.ResolveSynthesizer(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...) + + comp := &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: apiv1.SynthesizerRef{LabelSelector: tt.selector}}} + synth, err := comp.ResolveSynthesizer(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 + }, + }) + + comp := &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: apiv1.SynthesizerRef{Name: "test-synth"}}} + synth, err := comp.ResolveSynthesizer(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 + }, + }) + + comp := &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: apiv1.SynthesizerRef{ + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}, + }, + }}} + synth, err := comp.ResolveSynthesizer(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") + }, + }) + + comp := &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: apiv1.SynthesizerRef{Name: "missing-synth"}}} + synth, err := comp.ResolveSynthesizer(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) + + comp := &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: apiv1.SynthesizerRef{Name: "no-labels-synth"}}} + result, err := comp.ResolveSynthesizer(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) + + comp := &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: apiv1.SynthesizerRef{Name: "full-spec-synth"}}} + result, err := comp.ResolveSynthesizer(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) + + comp := &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: apiv1.SynthesizerRef{ + LabelSelector: &metav1.LabelSelector{ + MatchLabels: nil, + MatchExpressions: nil, + }, + }}} + result, err := comp.ResolveSynthesizer(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) + + comp := &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: apiv1.SynthesizerRef{Name: "my-synth-v1.2.3"}}} + result, err := comp.ResolveSynthesizer(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) + + comp := &apiv1.Composition{Spec: apiv1.CompositionSpec{Synthesizer: apiv1.SynthesizerRef{Name: "test-synth"}}} + _, err := comp.ResolveSynthesizer(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/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..f3e037f5 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,105 @@ +# End-to-End Tests + +This directory contains e2e tests that run against a live Kubernetes cluster with +Eno's controllers deployed. They verify full lifecycle flows — creating +Synthesizers/Compositions, waiting for synthesis, checking outputs, updating, +and deleting. + +## Prerequisites + +- A running Kubernetes cluster with Eno controllers deployed. +- `KUBECONFIG` set (or in-cluster config available). + +## Running E2E Tests + +```bash +# Run all e2e tests +make test-e2e +``` + + + +## Test Structure +Each test file (`*_test.go`) defines a workflow as a **directed acyclic graph +(DAG)** that can run no dependency steps in parallel automatically. + +A test follows three phases: + +### 1. Define resources and variables + +```go +cli := fw.NewClient(t) +synthName := fw.UniqueName("my-synth") +synth := fw.NewMinimalSynthesizer(synthName, fw.WithCommand(fw.ToCommand(cm))) +comp := fw.NewComposition(compName, "default", fw.WithSynthesizerRefs(apiv1.SynthesizerRef{Name: synthName})) +``` + +### 2. Define steps + +Use framework helpers for common operations: + +```go +createSynth := fw.CreateStep(t, "createSynth", cli, synth) // creates a resource +deleteSynth := fw.DeleteStep(t, "deleteSynth", cli, synth) // deletes a resource +cleanup := fw.CleanupStep(t, "cleanup", cli, synth, comp) // deletes + waits for NotFound +``` + +For custom logic, use `flow.Func`: + +```go +verify := flow.Func("verify", func(ctx context.Context) error { + fw.WaitForResourceExists(t, ctx, cli, &cm, 30*time.Second) + assert.Equal(t, "expected", cm.Data["key"]) + return nil +}) +``` + +### 3. Wire the DAG and execute + +```go +w := new(flow.Workflow) +w.Add( + flow.Step(createComp).DependsOn(createSynth), // sequential + flow.Step(waitReady).DependsOn(createComp), + flow.Step(verifyA).DependsOn(waitReady), // verifyA and verifyB + flow.Step(verifyB).DependsOn(waitReady), // run in parallel + flow.Step(cleanup).DependsOn(verifyA, verifyB), // waits for both +) +require.NoError(t, w.Do(ctx)) +``` + +## Framework Utilities (`e2e/framework/`) + +| File | Contents | +|------|----------| +| `framework.go` | `NewClient` — creates a controller-runtime client from KUBECONFIG. | +| `crud.go` | Resource builders (`NewMinimalSynthesizer`, `NewComposition`, `NewSymphony`), `ToCommand` for producing synthesizer commands, and workflow step helpers (`CreateStep`, `DeleteStep`, `CleanupStep`). | +| `testutils.go` | Polling helpers: `WaitForCompositionReady`, `WaitForCompositionResynthesized`, `WaitForSymphonyReady`, `WaitForResourceExists`, `WaitForResourceDeleted`. | + +## CI Pipeline + +The **E2E Tests** workflow (`.github/workflows/e2e.yaml`) runs automatically on +every push and on pull requests targeting `main`. It also runs on a daily +schedule. No manual action is needed — opening a PR will trigger it. + +The pipeline: + +1. Creates a Kind cluster and deploys Eno into it. +2. The **"Run E2E tests"** output test names, pass/fail status, and log lines so you can + follow progress directly in the GitHub Actions log. +3. If any test fails, a **"Dump diagnostics"** step runs automatically and + prints the following to the job output: + - Controller and Reconciler pod logs + - Kubernetes events + - Full YAML of all Compositions, Synthesizers, and ResourceSlices + - Pods across all namespaces + +## Adding a New Test + +1. Create a `*_test.go` file in `e2e/` (package `e2e`). +2. Build resources with the `fw.*` helpers or manually construct them. +3. Define steps — prefer `fw.CreateStep`/`fw.DeleteStep`/`fw.CleanupStep` for + CRUD and `flow.Func` for assertions and custom logic. +4. Wire them into a DAG with `flow.Step(...).DependsOn(...)`. +5. Call `w.Do(ctx)` and assert no error. +6. Always clean up created resources at the end of the DAG. diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go new file mode 100644 index 00000000..25f0126e --- /dev/null +++ b/e2e/e2e_test.go @@ -0,0 +1,10 @@ +package e2e + +import ( + "os" + "testing" +) + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} diff --git a/e2e/framework/crud.go b/e2e/framework/crud.go new file mode 100644 index 00000000..268f081f --- /dev/null +++ b/e2e/framework/crud.go @@ -0,0 +1,157 @@ +package framework + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + flow "github.com/Azure/go-workflow" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + apiv1 "github.com/Azure/eno/api/v1" + krmv1 "github.com/Azure/eno/pkg/krm/functions/api/v1" +) + +// SynthesizerOption configures optional fields on a Synthesizer. +type SynthesizerOption func(*apiv1.Synthesizer) + +// WithCommand sets the Synthesizer's command. +func WithCommand(cmd []string) SynthesizerOption { + return func(s *apiv1.Synthesizer) { s.Spec.Command = cmd } +} + +// WithImage sets the Synthesizer's container image. +func WithImage(image string) SynthesizerOption { + return func(s *apiv1.Synthesizer) { s.Spec.Image = image } +} + +// WithLabels sets the Synthesizer's labels. +func WithLabels(labels map[string]string) SynthesizerOption { + return func(s *apiv1.Synthesizer) { s.Labels = labels } +} + +// NewMinimalSynthesizer builds a Synthesizer with sensible defaults. +// Only the name is required; use WithImage, WithCommand, and WithLabels to customise. +func NewMinimalSynthesizer(name string, opts ...SynthesizerOption) *apiv1.Synthesizer { + synth := &apiv1.Synthesizer{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: apiv1.SynthesizerSpec{ + Image: "docker.io/ubuntu:latest", + }, + } + for _, o := range opts { + o(synth) + } + return synth +} + +// CompositionOption configures optional fields on a Composition. +type CompositionOption func(*apiv1.CompositionSpec) + +// WithSynthesizerRefs sets the Composition's synthesizer reference. +func WithSynthesizerRefs(ref apiv1.SynthesizerRef) CompositionOption { + return func(s *apiv1.CompositionSpec) { s.Synthesizer = ref } +} + +// NewComposition builds a Composition in the given namespace. +// Use WithSynthesizerRefs to bind it to a Synthesizer. +func NewComposition(name, ns string, opts ...CompositionOption) *apiv1.Composition { + spec := apiv1.CompositionSpec{} + for _, o := range opts { + o(&spec) + } + return &apiv1.Composition{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + }, + Spec: spec, + } +} + +// ToCommand converts Kubernetes objects into a bash command that echoes them as a +// KRM ResourceList on stdout — exactly what a synthesizer pod is expected to do. +// Each object must have its APIVersion and Kind set (e.g. corev1.ConfigMap with +// its TypeMeta populated, or an unstructured.Unstructured). +func ToCommand(objs ...client.Object) []string { + items := make([]*unstructured.Unstructured, 0, len(objs)) + for _, obj := range objs { + raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + panic(fmt.Sprintf("ToCommand: converting %s to unstructured: %v", obj.GetName(), err)) + } + items = append(items, &unstructured.Unstructured{Object: raw}) + } + + rl := &krmv1.ResourceList{ + APIVersion: "config.kubernetes.io/v1", + Kind: "ResourceList", + Items: items, + } + + data, err := json.Marshal(rl) + if err != nil { + panic(fmt.Sprintf("ToCommand: marshalling ResourceList: %v", err)) + } + + return []string{"/bin/bash", "-c", fmt.Sprintf("echo %q", string(data))} +} + +// NewSymphony builds a Symphony with one variation per synthesizer name. +func NewSymphony(name, ns string, synthNames ...string) *apiv1.Symphony { + variations := make([]apiv1.Variation, len(synthNames)) + for i, sn := range synthNames { + variations[i] = apiv1.Variation{ + Synthesizer: apiv1.SynthesizerRef{Name: sn}, + } + } + return &apiv1.Symphony{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + Spec: apiv1.SymphonySpec{Variations: variations}, + } +} + +// Cleanup deletes an object and waits for it to be gone. +func Cleanup(t *testing.T, ctx context.Context, cli client.Client, obj client.Object) { + t.Helper() + err := cli.Delete(ctx, obj) + if apierrors.IsNotFound(err) { + return + } + require.NoError(t, err, "failed to delete %s", obj.GetName()) + WaitForResourceDeleted(t, ctx, cli, obj, 60*time.Second) +} + +// CreateStep returns a workflow step that creates the given object. +func CreateStep(t *testing.T, name string, cli client.Client, obj client.Object) flow.Steper { + return flow.Func(name, func(ctx context.Context) error { + t.Logf("creating %s", obj.GetName()) + return cli.Create(ctx, obj) + }) +} + +// DeleteStep returns a workflow step that deletes the given object. +func DeleteStep(t *testing.T, name string, cli client.Client, obj client.Object) flow.Steper { + return flow.Func(name, func(ctx context.Context) error { + t.Logf("deleting %s", obj.GetName()) + return cli.Delete(ctx, obj) + }) +} + +// CleanupStep returns a workflow step that cleans up the given objects. +func CleanupStep(t *testing.T, name string, cli client.Client, objs ...client.Object) flow.Steper { + return flow.Func(name, func(ctx context.Context) error { + for _, obj := range objs { + Cleanup(t, ctx, cli, obj) + } + t.Logf("cleanup complete: %s", name) + return nil + }) +} diff --git a/e2e/framework/framework.go b/e2e/framework/framework.go new file mode 100644 index 00000000..856dba63 --- /dev/null +++ b/e2e/framework/framework.go @@ -0,0 +1,37 @@ +package framework + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + apiv1 "github.com/Azure/eno/api/v1" +) + +func init() { + err := apiv1.SchemeBuilder.AddToScheme(scheme.Scheme) + if err != nil { + panic(fmt.Sprintf("failed to add eno scheme: %v", err)) + } +} + +// NewClient creates a controller-runtime client using the in-cluster or KUBECONFIG config. +func NewClient(t *testing.T) client.Client { + t.Helper() + cfg, err := ctrl.GetConfig() + require.NoError(t, err, "failed to get kubeconfig — is KUBECONFIG set?") + + cli, err := client.New(cfg, client.Options{Scheme: scheme.Scheme}) + require.NoError(t, err, "failed to create client") + return cli +} + +// UniqueName generates a test-unique resource name with a timestamp suffix. +func UniqueName(prefix string) string { + return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()%100000) +} diff --git a/e2e/framework/testutils.go b/e2e/framework/testutils.go new file mode 100644 index 00000000..84e6ffeb --- /dev/null +++ b/e2e/framework/testutils.go @@ -0,0 +1,120 @@ +package framework + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/controller-runtime/pkg/client" + + apiv1 "github.com/Azure/eno/api/v1" +) + +// CompositionPredicate is a function that inspects a Composition and returns true when the +// desired condition is met. The message return value is used for logging while polling. +type CompositionPredicate func(*apiv1.Composition) (done bool, msg string) + +// WaitForCompositionAsExpected polls until the given predicate returns true for the composition. +func WaitForCompositionAsExpected(t *testing.T, ctx context.Context, cli client.Client, key types.NamespacedName, timeout time.Duration, pred CompositionPredicate) { + t.Helper() + err := wait.PollUntilContextTimeout(ctx, 2*time.Second, timeout, true, func(ctx context.Context) (bool, error) { + comp := &apiv1.Composition{} + if err := cli.Get(ctx, key, comp); err != nil { + return false, nil + } + done, msg := pred(comp) + if !done { + t.Logf("composition %s: %s", key.Name, msg) + } + return done, nil + }) + require.NoError(t, err, "timed out waiting for composition %s to meet expected condition", key.Name) +} + +// CompositionIsReady returns a predicate that checks whether the composition's +// Simplified.Status equals "Ready". +func CompositionIsReady() CompositionPredicate { + return func(comp *apiv1.Composition) (bool, string) { + if comp.Status.Simplified == nil { + return false, "no simplified status yet" + } + return comp.Status.Simplified.Status == "Ready", + fmt.Sprintf("status: %s", comp.Status.Simplified.String()) + } +} + +// CompositionResynthesized returns a predicate that checks whether the composition's +// ObservedSynthesizerGeneration has advanced beyond minGen AND status is "Ready". +func CompositionResynthesized(minGen int64) CompositionPredicate { + return func(comp *apiv1.Composition) (bool, string) { + if comp.Status.CurrentSynthesis == nil { + return false, "no current synthesis yet" + } + gen := comp.Status.CurrentSynthesis.ObservedSynthesizerGeneration + if gen <= minGen { + return false, fmt.Sprintf("waiting for synth gen > %d (current: %d)", minGen, gen) + } + if comp.Status.Simplified == nil || comp.Status.Simplified.Status != "Ready" { + return false, fmt.Sprintf("synth gen advanced to %d, waiting for Ready (current: %s)", gen, comp.Status.Simplified.String()) + } + return true, "" + } +} + +// WaitForCompositionReady polls until the composition's Simplified.Status equals "Ready". +func WaitForCompositionReady(t *testing.T, ctx context.Context, cli client.Client, key types.NamespacedName, timeout time.Duration) { + t.Helper() + WaitForCompositionAsExpected(t, ctx, cli, key, timeout, CompositionIsReady()) +} + +// WaitForCompositionResynthesized polls until the composition's ObservedSynthesizerGeneration +// advances beyond minGen AND status returns to "Ready". +func WaitForCompositionResynthesized(t *testing.T, ctx context.Context, cli client.Client, key types.NamespacedName, minGen int64, timeout time.Duration) { + t.Helper() + WaitForCompositionAsExpected(t, ctx, cli, key, timeout, CompositionResynthesized(minGen)) +} + +// WaitForSymphonyReady polls until the symphony's Status.Ready is non-nil. +func WaitForSymphonyReady(t *testing.T, ctx context.Context, cli client.Client, key types.NamespacedName, timeout time.Duration) { + t.Helper() + err := wait.PollUntilContextTimeout(ctx, 2*time.Second, timeout, true, func(ctx context.Context) (bool, error) { + sym := &apiv1.Symphony{} + if err := cli.Get(ctx, key, sym); err != nil { + return false, nil + } + return sym.Status.Ready != nil, nil + }) + require.NoError(t, err, "timed out waiting for symphony %s to become Ready", key.Name) +} + +// WaitForResourceExists polls until the given object can be fetched. +func WaitForResourceExists(t *testing.T, ctx context.Context, cli client.Client, obj client.Object, timeout time.Duration) { + t.Helper() + key := client.ObjectKeyFromObject(obj) + err := wait.PollUntilContextTimeout(ctx, 2*time.Second, timeout, true, func(ctx context.Context) (bool, error) { + if err := cli.Get(ctx, key, obj); err != nil { + return false, nil + } + return true, nil + }) + require.NoError(t, err, "timed out waiting for %s %s to exist", obj.GetObjectKind().GroupVersionKind().Kind, key) +} + +// WaitForResourceDeleted polls until the given object returns NotFound. +func WaitForResourceDeleted(t *testing.T, ctx context.Context, cli client.Client, obj client.Object, timeout time.Duration) { + t.Helper() + key := client.ObjectKeyFromObject(obj) + err := wait.PollUntilContextTimeout(ctx, 2*time.Second, timeout, true, func(ctx context.Context) (bool, error) { + err := cli.Get(ctx, key, obj) + if apierrors.IsNotFound(err) { + return true, nil + } + return false, nil + }) + require.NoError(t, err, "timed out waiting for %s %s to be deleted", obj.GetObjectKind().GroupVersionKind().Kind, key) +} diff --git a/e2e/label_selector_test.go b/e2e/label_selector_test.go new file mode 100644 index 00000000..ac31379b --- /dev/null +++ b/e2e/label_selector_test.go @@ -0,0 +1,165 @@ +package e2e + +import ( + "context" + "fmt" + "testing" + "time" + + flow "github.com/Azure/go-workflow" + "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/types" + + apiv1 "github.com/Azure/eno/api/v1" + fw "github.com/Azure/eno/e2e/framework" +) + +func TestLabelSelectorSynthesizerResolution(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + cli := fw.NewClient(t) + + synthNameV1 := fw.UniqueName("selector-e2e-v1") + synthNameV2 := fw.UniqueName("selector-e2e-v2") + compName := fw.UniqueName("selector-e2e-comp") + cmName := fw.UniqueName("selector-e2e-cm") + + // ConfigMaps produced by each synthesizer version. + cmV1 := &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"}, + ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: "default"}, + Data: map[string]string{"version": "v1"}, + } + cmV2 := &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"}, + ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: "default"}, + Data: map[string]string{"version": "v2"}, + } + + // Use a unique label value so this test's selector won't match synthesizers from other tests. + appLabel := compName // reuse the unique comp name as the app label value + + synthV1 := fw.NewMinimalSynthesizer(synthNameV1, + fw.WithLabels(map[string]string{"app": appLabel, "version": "v1"}), + fw.WithCommand(fw.ToCommand(cmV1)), + ) + synthV2 := fw.NewMinimalSynthesizer(synthNameV2, + fw.WithLabels(map[string]string{"app": appLabel, "version": "v2"}), + fw.WithCommand(fw.ToCommand(cmV2)), + ) + + comp := fw.NewComposition(compName, "default", fw.WithSynthesizerRefs(apiv1.SynthesizerRef{ + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": appLabel, "version": "v1"}, + }, + })) + compKey := types.NamespacedName{Name: compName, Namespace: "default"} + + // -- Define workflow steps -- + + createSynthV1 := fw.CreateStep(t, "createSynthV1", cli, synthV1) + createSynthV2 := fw.CreateStep(t, "createSynthV2", cli, synthV2) + + createComposition := fw.CreateStep(t, "createComposition", cli, comp) + + waitV1Ready := flow.Func("waitV1Ready", func(ctx context.Context) error { + fw.WaitForCompositionReady(t, ctx, cli, compKey, 3*time.Minute) + return nil + }) + + verifyV1Output := flow.Func("verifyV1Output", func(ctx context.Context) error { + // Verify resolved synth name. + require.NoError(t, cli.Get(ctx, compKey, comp)) + require.NotNil(t, comp.Status.Simplified, "simplified status should be set") + assert.Equal(t, synthNameV1, comp.Status.Simplified.ResolvedSynthName, + "should resolve to v1 synthesizer") + + // Verify the ConfigMap has v1 data. + cm := corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: "default"}, + } + fw.WaitForResourceExists(t, ctx, cli, &cm, 30*time.Second) + assert.Equal(t, "v1", cm.Data["version"], "expected v1 ConfigMap") + t.Log("verified v1 synthesis output") + return nil + }) + + updateSelector := flow.Func("updateSelector", func(ctx context.Context) error { + // Re-fetch to get latest resourceVersion. + require.NoError(t, cli.Get(ctx, compKey, comp)) + comp.Spec.Synthesizer.LabelSelector.MatchLabels = map[string]string{ + "app": appLabel, + "version": "v2", + } + t.Log("updating composition label selector to target v2") + return cli.Update(ctx, comp) + }) + + waitV2Ready := flow.Func("waitV2Ready", func(ctx context.Context) error { + fw.WaitForCompositionAsExpected(t, ctx, cli, compKey, 3*time.Minute, + func(c *apiv1.Composition) (bool, string) { + if c.Status.Simplified == nil { + return false, "waiting for simplified status" + } + if c.Status.Simplified.ResolvedSynthName != synthNameV2 { + return false, fmt.Sprintf("resolvedSynthName=%q, want %q", + c.Status.Simplified.ResolvedSynthName, synthNameV2) + } + if c.Status.Simplified.Status != "Ready" { + return false, fmt.Sprintf("status=%q, want Ready", c.Status.Simplified.Status) + } + return true, "" + }) + t.Log("composition resolved to v2 synthesizer and is Ready") + return nil + }) + + verifyV2Output := flow.Func("verifyV2Output", func(ctx context.Context) error { + // Verify the ConfigMap now has v2 data. + cm := corev1.ConfigMap{} + require.NoError(t, cli.Get(ctx, types.NamespacedName{Name: cmName, Namespace: "default"}, &cm)) + assert.Equal(t, "v2", cm.Data["version"], "expected v2 ConfigMap") + t.Log("verified v2 ConfigMap output") + return nil + }) + + deleteComposition := fw.DeleteStep(t, "deleteComposition", cli, comp) + + verifyOutputDeleted := flow.Func("verifyOutputDeleted", func(ctx context.Context) error { + cm := corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: "default"}, + } + fw.WaitForResourceDeleted(t, ctx, cli, &cm, 60*time.Second) + t.Log("verified ConfigMap deleted") + return nil + }) + + cleanup := fw.CleanupStep(t, "cleanup", cli, synthV1, synthV2) + + // -- Wire the DAG -- + + w := new(flow.Workflow) + w.Add( + // Both synthesizers can be created in parallel; composition depends on both. + flow.Step(createComposition).DependsOn(createSynthV1, createSynthV2), + flow.Step(waitV1Ready).DependsOn(createComposition), + flow.Step(verifyV1Output).DependsOn(waitV1Ready), + + // Switch selector to v2. + flow.Step(updateSelector).DependsOn(verifyV1Output), + flow.Step(waitV2Ready).DependsOn(updateSelector), + flow.Step(verifyV2Output).DependsOn(waitV2Ready), + + // Cleanup. + flow.Step(deleteComposition).DependsOn(verifyV2Output), + flow.Step(verifyOutputDeleted).DependsOn(deleteComposition), + flow.Step(cleanup).DependsOn(verifyOutputDeleted), + ) + + require.NoError(t, w.Do(ctx)) +} diff --git a/e2e/lifecycle_test.go b/e2e/lifecycle_test.go new file mode 100644 index 00000000..2c50111c --- /dev/null +++ b/e2e/lifecycle_test.go @@ -0,0 +1,123 @@ +package e2e + +import ( + "context" + "testing" + "time" + + flow "github.com/Azure/go-workflow" + "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/types" + + apiv1 "github.com/Azure/eno/api/v1" + fw "github.com/Azure/eno/e2e/framework" +) + +func TestMinimalLifecycle(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + cli := fw.NewClient(t) + + synthName := fw.UniqueName("lifecycle-synth") + compName := fw.UniqueName("lifecycle-comp") + cmName := fw.UniqueName("lifecycle-cm") + cmInit := &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"}, + ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: "default"}, + Data: map[string]string{"someKey": "initialValue"}, + } + cmUpdated := &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"}, + ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: "default"}, + Data: map[string]string{"someKey": "updatedValue"}, + } + synth := fw.NewMinimalSynthesizer(synthName, fw.WithCommand(fw.ToCommand(cmInit))) + comp := fw.NewComposition(compName, "default", fw.WithSynthesizerRefs(apiv1.SynthesizerRef{Name: synthName})) + compKey := types.NamespacedName{Name: compName, Namespace: "default"} + + // Track the initial synthesizer generation after creation. + var initialSynthGen int64 + + // -- Define workflow steps -- + + createSynthesizer := fw.CreateStep(t, "createSynthesizer", cli, synth) + + createComposition := fw.CreateStep(t, "createComposition", cli, comp) + + waitReady := flow.Func("waitReady", func(ctx context.Context) error { + fw.WaitForCompositionReady(t, ctx, cli, compKey, 3*time.Minute) + // Capture the initial synthesizer generation. + require.NoError(t, cli.Get(ctx, compKey, comp)) + require.NotNil(t, comp.Status.CurrentSynthesis) + initialSynthGen = comp.Status.CurrentSynthesis.ObservedSynthesizerGeneration + t.Logf("initial ObservedSynthesizerGeneration: %d", initialSynthGen) + return nil + }) + + verifyOutputConfigMap := flow.Func("verifyOutputConfigMap", func(ctx context.Context) error { + cm := corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: "default"}, + } + fw.WaitForResourceExists(t, ctx, cli, &cm, 30*time.Second) + assert.Equal(t, "initialValue", cm.Data["someKey"], "expected initial ConfigMap value") + t.Log("verified initial ConfigMap output") + return nil + }) + + updateSynthesizer := flow.Func("updateSynthesizer", func(ctx context.Context) error { + // Re-fetch to get latest resourceVersion. + require.NoError(t, cli.Get(ctx, types.NamespacedName{Name: synthName}, synth)) + synth.Spec.Command = fw.ToCommand(cmUpdated) + t.Log("updating synthesizer to produce updatedValue") + return cli.Update(ctx, synth) + }) + + waitReadyAfterResynthesis := flow.Func("waitReadyAfterResynthesis", func(ctx context.Context) error { + fw.WaitForCompositionResynthesized(t, ctx, cli, compKey, initialSynthGen, 3*time.Minute) + t.Log("composition re-synthesized and ready") + return nil + }) + + verifyUpdatedOutput := flow.Func("verifyUpdatedOutput", func(ctx context.Context) error { + cm := corev1.ConfigMap{} + require.NoError(t, cli.Get(ctx, types.NamespacedName{Name: cmName, Namespace: "default"}, &cm)) + assert.Equal(t, "updatedValue", cm.Data["someKey"], "expected updated ConfigMap value") + t.Log("verified updated ConfigMap output") + return nil + }) + + deleteComposition := fw.DeleteStep(t, "deleteComposition", cli, comp) + + verifyOutputDeleted := flow.Func("verifyOutputDeleted", func(ctx context.Context) error { + cm := corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: "default"}, + } + fw.WaitForResourceDeleted(t, ctx, cli, &cm, 60*time.Second) + t.Log("verified ConfigMap deleted") + return nil + }) + + cleanupSynthesizer := fw.CleanupStep(t, "cleanupSynthesizer", cli, synth) + + // -- Wire the DAG -- + + w := new(flow.Workflow) + w.Add( + flow.Step(createComposition).DependsOn(createSynthesizer), + flow.Step(waitReady).DependsOn(createComposition), + flow.Step(verifyOutputConfigMap).DependsOn(waitReady), + flow.Step(updateSynthesizer).DependsOn(verifyOutputConfigMap), + flow.Step(waitReadyAfterResynthesis).DependsOn(updateSynthesizer), + flow.Step(verifyUpdatedOutput).DependsOn(waitReadyAfterResynthesis), + flow.Step(deleteComposition).DependsOn(verifyUpdatedOutput), + flow.Step(verifyOutputDeleted).DependsOn(deleteComposition), + flow.Step(cleanupSynthesizer).DependsOn(verifyOutputDeleted), + ) + + require.NoError(t, w.Do(ctx)) +} diff --git a/e2e/symphony_test.go b/e2e/symphony_test.go new file mode 100644 index 00000000..f5029de8 --- /dev/null +++ b/e2e/symphony_test.go @@ -0,0 +1,164 @@ +package e2e + +import ( + "context" + "testing" + "time" + + flow "github.com/Azure/go-workflow" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + apiv1 "github.com/Azure/eno/api/v1" + fw "github.com/Azure/eno/e2e/framework" +) + +func TestSymphonyLifecycle(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + cli := fw.NewClient(t) + + synthName1 := fw.UniqueName("sym-synth-1") + synthName2 := fw.UniqueName("sym-synth-2") + symphonyName := fw.UniqueName("sym-test") + cmName1 := fw.UniqueName("sym-cm-1") + cmName2 := fw.UniqueName("sym-cm-2") + + cm1 := &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"}, + ObjectMeta: metav1.ObjectMeta{Name: cmName1, Namespace: "default"}, + Data: map[string]string{"source": "synth1"}, + } + cm2 := &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"}, + ObjectMeta: metav1.ObjectMeta{Name: cmName2, Namespace: "default"}, + Data: map[string]string{"source": "synth2"}, + } + + synth1 := fw.NewMinimalSynthesizer(synthName1, fw.WithCommand(fw.ToCommand(cm1))) + synth2 := fw.NewMinimalSynthesizer(synthName2, fw.WithCommand(fw.ToCommand(cm2))) + + symphony := fw.NewSymphony(symphonyName, "default", synthName1, synthName2) + symphonyKey := types.NamespacedName{Name: symphonyName, Namespace: "default"} + + // -- Steps -- + + createSynth1 := fw.CreateStep(t, "createSynth1", cli, synth1) + + createSynth2 := fw.CreateStep(t, "createSynth2", cli, synth2) + + createSymphony := fw.CreateStep(t, "createSymphony", cli, symphony) + + waitSymphonyReady := flow.Func("waitSymphonyReady", func(ctx context.Context) error { + fw.WaitForSymphonyReady(t, ctx, cli, symphonyKey, 3*time.Minute) + t.Log("symphony is ready") + return nil + }) + + verifySymphonyExists := flow.Func("verifySymphonyExists", func(ctx context.Context) error { + sym := &apiv1.Symphony{} + err := cli.Get(ctx, symphonyKey, sym) + require.NoError(t, err, "symphony should exist") + t.Log("symphony exists") + return nil + }) + + verifyCompositionsExist := flow.Func("verifyCompositionsExist", func(ctx context.Context) error { + compList := &apiv1.CompositionList{} + err := cli.List(ctx, compList, client.InNamespace("default")) + require.NoError(t, err) + count := 0 + for _, c := range compList.Items { + for _, ref := range c.OwnerReferences { + if ref.Name == symphonyName { + count++ + } + } + } + require.Equal(t, 2, count, "expected 2 compositions owned by symphony") + t.Log("2 compositions exist") + return nil + }) + + verifyResourceSlicesExist := flow.Func("verifyResourceSlicesExist", func(ctx context.Context) error { + sliceList := &apiv1.ResourceSliceList{} + err := cli.List(ctx, sliceList, client.InNamespace("default")) + require.NoError(t, err) + require.NotEmpty(t, sliceList.Items, "expected at least one ResourceSlice") + t.Logf("%d ResourceSlice(s) exist", len(sliceList.Items)) + return nil + }) + + verifySynthesizersExist := flow.Func("verifySynthesizersExist", func(ctx context.Context) error { + s1 := &apiv1.Synthesizer{} + require.NoError(t, cli.Get(ctx, types.NamespacedName{Name: synthName1}, s1), "synthesizer 1 should exist") + s2 := &apiv1.Synthesizer{} + require.NoError(t, cli.Get(ctx, types.NamespacedName{Name: synthName2}, s2), "synthesizer 2 should exist") + t.Log("both synthesizers exist") + return nil + }) + + verifyConfigMapsExist := flow.Func("verifyConfigMapsExist", func(ctx context.Context) error { + cm1 := corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: cmName1, Namespace: "default"}, + } + cm2 := corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: cmName2, Namespace: "default"}, + } + fw.WaitForResourceExists(t, ctx, cli, &cm1, 30*time.Second) + fw.WaitForResourceExists(t, ctx, cli, &cm2, 30*time.Second) + t.Log("both ConfigMaps exist") + return nil + }) + + deleteSymphony := fw.DeleteStep(t, "deleteSymphony", cli, symphony) + + verifyCleanup := flow.Func("verifyCleanup", func(ctx context.Context) error { + // Symphony deletion orphans managed resources (by design), so the + // ConfigMaps should still exist after the symphony and its + // compositions are removed. + fw.WaitForResourceDeleted(t, ctx, cli, symphony, 60*time.Second) + t.Log("symphony is gone") + + cm1 := corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: cmName1, Namespace: "default"}, + } + cm2 := corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: cmName2, Namespace: "default"}, + } + fw.WaitForResourceExists(t, ctx, cli, &cm1, 30*time.Second) + fw.WaitForResourceExists(t, ctx, cli, &cm2, 30*time.Second) + t.Log("both ConfigMaps still exist (orphaned)") + return nil + }) + + cleanupSynthesizers := fw.CleanupStep(t, "cleanupAll", cli, cm1, cm2, synth1, synth2) + + // -- Wire the DAG -- + // createSynth1 and createSynth2 run in parallel (no mutual dependency). + + w := new(flow.Workflow) + w.Add( + flow.Step(createSymphony).DependsOn(createSynth1, createSynth2), + flow.Step(waitSymphonyReady).DependsOn(createSymphony), + + // Parallel verification — all depend on waitSymphonyReady + flow.Step(verifySymphonyExists).DependsOn(waitSymphonyReady), + flow.Step(verifyCompositionsExist).DependsOn(waitSymphonyReady), + flow.Step(verifyResourceSlicesExist).DependsOn(waitSymphonyReady), + flow.Step(verifySynthesizersExist).DependsOn(waitSymphonyReady), + flow.Step(verifyConfigMapsExist).DependsOn(waitSymphonyReady), + + // deleteSymphony waits for all verifications + flow.Step(deleteSymphony).DependsOn(verifySymphonyExists, verifyCompositionsExist, verifyResourceSlicesExist, verifySynthesizersExist, verifyConfigMapsExist), + flow.Step(verifyCleanup).DependsOn(deleteSymphony), + flow.Step(cleanupSynthesizers).DependsOn(verifyCleanup), + ) + + require.NoError(t, w.Do(ctx)) +} diff --git a/go.mod b/go.mod index df463216..8ea3455a 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.0 toolchain go1.24.13 require ( + github.com/Azure/go-workflow v0.1.13 github.com/alecthomas/participle/v2 v2.1.4 github.com/emirpasic/gods/v2 v2.0.0-alpha github.com/go-logr/logr v1.4.3 @@ -31,7 +32,9 @@ require ( require ( cel.dev/expr v0.24.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/benbjohnson/clock v1.3.5 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect diff --git a/go.sum b/go.sum index bf2141af..91c052e6 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +github.com/Azure/go-workflow v0.1.13 h1:tqYmmKsw068Uu/OKRIctQEe1H6BZyXs9mJtzocc7jtE= +github.com/Azure/go-workflow v0.1.13/go.mod h1:gOt4hadDnP+SzV+ywWJRcM1BSopJ+1rfGlhrTIR040I= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U= @@ -8,8 +10,12 @@ github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/internal/controllers/composition/controller.go b/internal/controllers/composition/controller.go index 52e83b58..1821861a 100644 --- a/internal/controllers/composition/controller.go +++ b/internal/controllers/composition/controller.go @@ -96,10 +96,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.ResolveSynthesizer(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 @@ -204,7 +201,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 } @@ -293,6 +290,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 19c3f886..893acb64 100644 --- a/internal/controllers/composition/controller_test.go +++ b/internal/controllers/composition/controller_test.go @@ -269,6 +269,156 @@ type simplifiedStatusState struct { 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) +} + func TestIsAddonComposition(t *testing.T) { tests := []struct { name string @@ -300,7 +450,6 @@ func TestIsAddonComposition(t *testing.T) { labels: map[string]string{AKSComponentLabel: addOnLabelValue}, expected: true, }, - } for _, tt := range tests { diff --git a/internal/controllers/scheduling/controller.go b/internal/controllers/scheduling/controller.go index c58dc61c..45406090 100644 --- a/internal/controllers/scheduling/controller.go +++ b/internal/controllers/scheduling/controller.go @@ -122,17 +122,23 @@ func (c *controller) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu 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..e0fbeaea 100644 --- a/internal/controllers/scheduling/controller_test.go +++ b/internal/controllers/scheduling/controller_test.go @@ -9,6 +9,7 @@ import ( "time" apiv1 "github.com/Azure/eno/api/v1" + "github.com/Azure/eno/internal/controllers/composition" "github.com/Azure/eno/internal/testutil" prometheustestutil "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/assert" @@ -28,6 +29,7 @@ func TestBasics(t *testing.T) { ctx := testutil.NewContext(t) mgr := testutil.NewManager(t) require.NoError(t, NewController(mgr.Manager, 100, 2*time.Second, 0)) + require.NoError(t, composition.NewController(mgr.Manager, time.Minute)) mgr.Start(t) cli := mgr.GetClient() @@ -98,6 +100,7 @@ func TestSynthRolloutBasics(t *testing.T) { ctx := testutil.NewContext(t) mgr := testutil.NewManager(t) require.NoError(t, NewController(mgr.Manager, 100, 2*time.Second, 0)) + require.NoError(t, composition.NewController(mgr.Manager, time.Minute)) mgr.Start(t) cli := mgr.GetClient() @@ -197,6 +200,7 @@ func TestDeferredInput(t *testing.T) { ctx := testutil.NewContext(t) mgr := testutil.NewManager(t) require.NoError(t, NewController(mgr.Manager, 100, 2*time.Second, 0)) + require.NoError(t, composition.NewController(mgr.Manager, time.Minute)) mgr.Start(t) cli := mgr.GetClient() @@ -269,6 +273,7 @@ func TestForcedResynth(t *testing.T) { ctx := testutil.NewContext(t) mgr := testutil.NewManager(t) require.NoError(t, NewController(mgr.Manager, 100, 2*time.Second, 0)) + require.NoError(t, composition.NewController(mgr.Manager, time.Minute)) mgr.Start(t) cli := mgr.GetClient() @@ -311,6 +316,7 @@ func TestChaos(t *testing.T) { t.Run("one leader", func(t *testing.T) { mgr := testutil.NewManager(t) require.NoError(t, NewController(mgr.Manager, 5, time.Second, 0)) + require.NoError(t, composition.NewController(mgr.Manager, time.Minute)) mgr.Start(t) testChaos(t, mgr) @@ -321,6 +327,7 @@ func TestChaos(t *testing.T) { mgr := testutil.NewManager(t) require.NoError(t, NewController(mgr.Manager, 5, time.Second, 0)) require.NoError(t, NewController(mgr.Manager, 5, time.Second, 0)) + require.NoError(t, composition.NewController(mgr.Manager, time.Minute)) mgr.Start(t) testChaos(t, mgr) @@ -443,6 +450,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 +503,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 +577,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)) @@ -616,6 +626,7 @@ func TestRetries(t *testing.T) { ctx := testutil.NewContext(t) mgr := testutil.NewManager(t) require.NoError(t, NewController(mgr.Manager, 100, 2*time.Second, 0)) + require.NoError(t, composition.NewController(mgr.Manager, time.Minute)) mgr.Start(t) cli := mgr.GetClient() @@ -673,6 +684,7 @@ func TestRetryContention(t *testing.T) { ctx := testutil.NewContext(t) mgr := testutil.NewManager(t) require.NoError(t, NewController(mgr.Manager, 1, 2*time.Second, 0)) + require.NoError(t, composition.NewController(mgr.Manager, time.Minute)) mgr.Start(t) cli := mgr.GetClient() @@ -736,6 +748,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 +763,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 +781,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..9e4bbf9e 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.ResolveSynthesizer(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/integration_test.go b/internal/controllers/synthesis/integration_test.go index 00f79816..82bb072e 100644 --- a/internal/controllers/synthesis/integration_test.go +++ b/internal/controllers/synthesis/integration_test.go @@ -17,6 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" apiv1 "github.com/Azure/eno/api/v1" + "github.com/Azure/eno/internal/controllers/composition" "github.com/Azure/eno/internal/controllers/scheduling" "github.com/Azure/eno/internal/testutil" krmv1 "github.com/Azure/eno/pkg/krm/functions/api/v1" @@ -37,6 +38,7 @@ func TestControllerHappyPath(t *testing.T) { require.NoError(t, scheduling.NewController(mgr.Manager, 10, 2*time.Second, time.Second)) require.NoError(t, NewPodLifecycleController(mgr.Manager, minimalTestConfig)) require.NoError(t, NewPodGC(mgr.Manager, 0)) + require.NoError(t, composition.NewController(mgr.Manager, time.Minute)) calls := atomic.Int64{} testutil.WithFakeExecutor(t, mgr, func(ctx context.Context, s *apiv1.Synthesizer, input *krmv1.ResourceList) (*krmv1.ResourceList, error) { @@ -118,6 +120,7 @@ func TestControllerFastCompositionUpdates(t *testing.T) { require.NoError(t, scheduling.NewController(mgr.Manager, 10, 2*time.Second, time.Second)) require.NoError(t, NewPodLifecycleController(mgr.Manager, minimalTestConfig)) require.NoError(t, NewPodGC(mgr.Manager, 0)) + require.NoError(t, composition.NewController(mgr.Manager, time.Minute)) testutil.WithFakeExecutor(t, mgr, func(ctx context.Context, s *apiv1.Synthesizer, input *krmv1.ResourceList) (*krmv1.ResourceList, error) { output := &krmv1.ResourceList{} // simulate real pods taking some random amount of time to generation @@ -198,6 +201,7 @@ func TestControllerSwitchingSynthesizers(t *testing.T) { require.NoError(t, scheduling.NewController(mgr.Manager, 10, 2*time.Second, time.Second)) require.NoError(t, NewPodLifecycleController(mgr.Manager, minimalTestConfig)) require.NoError(t, NewPodGC(mgr.Manager, 0)) + require.NoError(t, composition.NewController(mgr.Manager, time.Minute)) mgr.Start(t) syn1 := &apiv1.Synthesizer{} @@ -255,6 +259,7 @@ func TestDeadKubelet(t *testing.T) { require.NoError(t, scheduling.NewController(mgr.Manager, 10, 2*time.Second, time.Second)) require.NoError(t, NewPodLifecycleController(mgr.Manager, minimalTestConfig)) require.NoError(t, NewPodGC(mgr.Manager, 0)) + require.NoError(t, composition.NewController(mgr.Manager, time.Minute)) mgr.Start(t) syn := &apiv1.Synthesizer{} diff --git a/internal/controllers/synthesis/lifecycle.go b/internal/controllers/synthesis/lifecycle.go index d1044884..fb5c23d6 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.ResolveSynthesizer(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..9b6e3045 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.ResolveSynthesizer(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..f2ca68cd 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.ResolveSynthesizer(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..41271446 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.ResolveSynthesizer(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())