Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add synthesis controllers #13

Merged
merged 1 commit into from
Dec 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"go.testTimeout": "120s"
AYM1607 marked this conversation as resolved.
Show resolved Hide resolved
}
294 changes: 294 additions & 0 deletions internal/controllers/synthesis/integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
package synthesis

import (
"fmt"
"math/rand"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
"k8s.io/client-go/util/retry"
"sigs.k8s.io/controller-runtime/pkg/client"

apiv1 "github.com/Azure/eno/api/v1"
"github.com/Azure/eno/internal/testutil"
)

var minimalTestConfig = &Config{
WrapperImage: "test-wrapper-image",
MaxRestarts: 3,
Timeout: time.Second * 2,
}

func TestControllerHappyPath(t *testing.T) {
ctx := testutil.NewContext(t)
mgr := testutil.NewManager(t)
testutil.NewPodController(t, mgr.Manager, nil)
cli := mgr.GetClient()

require.NoError(t, NewPodLifecycleController(mgr.Manager, minimalTestConfig))
require.NoError(t, NewStatusController(mgr.Manager))
require.NoError(t, NewRolloutController(mgr.Manager, time.Millisecond*10))
mgr.Start(t)

syn := &apiv1.Synthesizer{}
syn.Name = "test-syn"
syn.Spec.Image = "test-syn-image"
require.NoError(t, cli.Create(ctx, syn))

comp := &apiv1.Composition{}
comp.Name = "test-comp"
comp.Namespace = "default"
comp.Spec.Synthesizer.Name = syn.Name
require.NoError(t, cli.Create(ctx, comp))

t.Run("initial creation", func(t *testing.T) {
// It creates a pod to synthesize the composition
testutil.Eventually(t, func() bool {
list := &corev1.PodList{}
require.NoError(t, cli.List(ctx, list))
return len(list.Items) > 0
})

// The pod eventually completes and is deleted
testutil.Eventually(t, func() bool {
list := &corev1.PodList{}
require.NoError(t, cli.List(ctx, list))
return len(list.Items) == 0
})

// The pod eventually writes a resource slice count to the status
testutil.Eventually(t, func() bool {
require.NoError(t, cli.Get(ctx, client.ObjectKeyFromObject(comp), comp))
return comp.Status.CurrentState != nil && comp.Status.CurrentState.ResourceSliceCount != nil
})
})

t.Run("composition update", func(t *testing.T) {
// Updating the composition should cause re-synthesis
err := retry.RetryOnConflict(retry.DefaultBackoff, func() error {
require.NoError(t, cli.Get(ctx, client.ObjectKeyFromObject(comp), comp))
comp.Spec.Inputs = []apiv1.InputRef{{Name: "anything"}}
return cli.Update(ctx, comp)
})
require.NoError(t, err)

latest := comp.Generation
testutil.Eventually(t, func() bool {
require.NoError(t, cli.Get(ctx, client.ObjectKeyFromObject(comp), comp))
return comp.Status.CurrentState != nil && comp.Status.CurrentState.ObservedCompositionGeneration == latest
})

// The previous state is retained
if comp.Status.PreviousState == nil {
t.Error("state wasn't swapped to previous")
} else {
assert.Equal(t, comp.Generation-1, comp.Status.PreviousState.ObservedCompositionGeneration)
}
})

t.Run("synthesizer update", func(t *testing.T) {
err := retry.RetryOnConflict(retry.DefaultBackoff, func() error {
if err := cli.Get(ctx, client.ObjectKeyFromObject(syn), syn); err != nil {
return err
}
syn.Spec.Image = "updated-image"
return cli.Update(ctx, syn)
})
require.NoError(t, err)

testutil.Eventually(t, func() bool {
require.NoError(t, cli.Get(ctx, client.ObjectKeyFromObject(comp), comp))
return comp.Status.CurrentState != nil && comp.Status.CurrentState.ObservedSynthesizerGeneration == syn.Generation
})

// The previous state is retained
if comp.Status.PreviousState == nil {
t.Error("state wasn't swapped to previous")
} else {
assert.Equal(t, syn.Generation-1, comp.Status.PreviousState.ObservedSynthesizerGeneration)
}
})

// The pod eventually completes and is deleted
testutil.Eventually(t, func() bool {
list := &corev1.PodList{}
require.NoError(t, cli.List(ctx, list))
return len(list.Items) == 0
})
}

func TestControllerFastCompositionUpdates(t *testing.T) {
ctx := testutil.NewContext(t)
mgr := testutil.NewManager(t)
cli := mgr.GetClient()
testutil.NewPodController(t, mgr.Manager, func(c *apiv1.Composition, s *apiv1.Synthesizer) []*apiv1.ResourceSlice {
// simulate real pods taking some random amount of time to generation
time.Sleep(time.Millisecond * time.Duration(rand.Int63n(300)))
return nil
})

require.NoError(t, NewPodLifecycleController(mgr.Manager, minimalTestConfig))
require.NoError(t, NewStatusController(mgr.Manager))
require.NoError(t, NewRolloutController(mgr.Manager, time.Millisecond*10))
mgr.Start(t)

syn := &apiv1.Synthesizer{}
syn.Name = "test-syn"
syn.Spec.Image = "test-syn-image"
require.NoError(t, cli.Create(ctx, syn))

comp := &apiv1.Composition{}
comp.Name = "test-comp"
comp.Namespace = "default"
comp.Spec.Synthesizer.Name = syn.Name
require.NoError(t, cli.Create(ctx, comp))

// Send a bunch of updates in a row
for i := 0; i < 10; i++ {
err := retry.RetryOnConflict(retry.DefaultBackoff, func() error {
err := cli.Get(ctx, client.ObjectKeyFromObject(comp), comp)
if client.IgnoreNotFound(err) != nil {
return err
}
comp.Spec.Inputs = []apiv1.InputRef{{
Name: fmt.Sprintf("some-unique-value-%d", i),
}}
return cli.Update(ctx, comp)
})
require.NoError(t, err)
}

// It should eventually converge even though pods did not terminate in order (due to jitter in testutil.NewPodController)
latest := comp.Generation
testutil.Eventually(t, func() bool {
require.NoError(t, cli.Get(ctx, client.ObjectKeyFromObject(comp), comp))
return comp.Status.CurrentState != nil && comp.Status.CurrentState.ObservedCompositionGeneration == latest
})
}

func TestControllerSynthesizerRollout(t *testing.T) {
ctx := testutil.NewContext(t)
mgr := testutil.NewManager(t)
testutil.NewPodController(t, mgr.Manager, nil)
cli := mgr.GetClient()

require.NoError(t, NewPodLifecycleController(mgr.Manager, minimalTestConfig))
require.NoError(t, NewStatusController(mgr.Manager))
require.NoError(t, NewRolloutController(mgr.Manager, time.Hour*24)) // Rollout should not continue during this test
mgr.Start(t)

syn := &apiv1.Synthesizer{}
syn.Name = "test-syn"
syn.Spec.Image = "test-syn-image"
require.NoError(t, cli.Create(ctx, syn))

comp1 := &apiv1.Composition{}
comp1.Name = "test-comp-1"
comp1.Namespace = "default"
comp1.Spec.Synthesizer.Name = syn.Name
require.NoError(t, cli.Create(ctx, comp1))

comp2 := &apiv1.Composition{}
comp2.Name = "test-comp-2"
comp2.Namespace = "default"
comp2.Spec.Synthesizer.Name = syn.Name
require.NoError(t, cli.Create(ctx, comp2))

// Wait for initial sync
testutil.Eventually(t, func() bool {
require.NoError(t, client.IgnoreNotFound(cli.Get(ctx, client.ObjectKeyFromObject(comp1), comp1)))
require.NoError(t, client.IgnoreNotFound(cli.Get(ctx, client.ObjectKeyFromObject(comp2), comp2)))
inSync1 := comp1.Status.CurrentState != nil && comp1.Status.CurrentState.ObservedSynthesizerGeneration == syn.Generation
inSync2 := comp2.Status.CurrentState != nil && comp2.Status.CurrentState.ObservedSynthesizerGeneration == syn.Generation
return inSync1 && inSync2
})

err := retry.RetryOnConflict(retry.DefaultBackoff, func() error {
if err := cli.Get(ctx, client.ObjectKeyFromObject(syn), syn); err != nil {
return err
}
syn.Spec.Image = "updated-image"
return cli.Update(ctx, syn)
})
require.NoError(t, err)

// One of the compositions should be updated but not the other because we set a RolloutCooldown of 1hr
assertRolloutPending := func() {
testutil.Eventually(t, func() bool {
require.NoError(t, client.IgnoreNotFound(cli.Get(ctx, client.ObjectKeyFromObject(comp1), comp1)))
require.NoError(t, client.IgnoreNotFound(cli.Get(ctx, client.ObjectKeyFromObject(comp2), comp2)))
inSync1 := comp1.Status.CurrentState != nil && comp1.Status.CurrentState.ObservedSynthesizerGeneration == syn.Generation
inSync2 := comp2.Status.CurrentState != nil && comp2.Status.CurrentState.ObservedSynthesizerGeneration == syn.Generation
return (inSync1 && !inSync2) || (!inSync1 && inSync2)
})
}

// Make sure the state persists
assertRolloutPending()
time.Sleep(time.Millisecond * 50)
assertRolloutPending()
}

func TestControllerSwitchingSynthesizers(t *testing.T) {
ctx := testutil.NewContext(t)
mgr := testutil.NewManager(t)
cli := mgr.GetClient()
testutil.NewPodController(t, mgr.Manager, func(c *apiv1.Composition, s *apiv1.Synthesizer) []*apiv1.ResourceSlice {
emptySlice := &apiv1.ResourceSlice{}
emptySlice.GenerateName = "test-"
emptySlice.Namespace = "default"

// return two slices for the second test synthesizer, we'll assert on that later
if s.Name == "test-syn-2" {
return []*apiv1.ResourceSlice{emptySlice.DeepCopy(), emptySlice.DeepCopy()}
}
return []*apiv1.ResourceSlice{emptySlice.DeepCopy()}
})

require.NoError(t, NewPodLifecycleController(mgr.Manager, minimalTestConfig))
require.NoError(t, NewStatusController(mgr.Manager))
require.NoError(t, NewRolloutController(mgr.Manager, time.Millisecond*10))
mgr.Start(t)

syn1 := &apiv1.Synthesizer{}
syn1.Name = "test-syn-1"
syn1.Spec.Image = "test-syn-image"
require.NoError(t, cli.Create(ctx, syn1))

syn2 := &apiv1.Synthesizer{}
syn2.Name = "test-syn-2"
syn2.Spec.Image = "initial-image"
require.NoError(t, cli.Create(ctx, syn2))

comp := &apiv1.Composition{}
comp.Name = "test-comp"
comp.Namespace = "default"
comp.Spec.Synthesizer.Name = syn1.Name
require.NoError(t, cli.Create(ctx, comp))

t.Run("initial creation", func(t *testing.T) {
testutil.Eventually(t, func() bool {
require.NoError(t, client.IgnoreNotFound(cli.Get(ctx, client.ObjectKeyFromObject(comp), comp)))
return comp.Status.CurrentState != nil && comp.Status.CurrentState.ResourceSliceCount != nil && *comp.Status.CurrentState.ResourceSliceCount == 1
})
})

t.Run("update synthesizer name", func(t *testing.T) {
err := retry.RetryOnConflict(retry.DefaultBackoff, func() error {
if err := cli.Get(ctx, client.ObjectKeyFromObject(comp), comp); err != nil {
return err
}
comp.Spec.Synthesizer.Name = syn2.Name
return cli.Update(ctx, comp)
})
require.NoError(t, err)

testutil.Eventually(t, func() bool {
require.NoError(t, cli.Get(ctx, client.ObjectKeyFromObject(comp), comp))
return comp.Status.CurrentState != nil && comp.Status.CurrentState.ResourceSliceCount != nil && *comp.Status.CurrentState.ResourceSliceCount == 2
})
})
}
Loading