consul/internal/controller/controller_test.go

741 lines
21 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package controller_test
import (
"context"
"errors"
"fmt"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
svctest "github.com/hashicorp/consul/agent/grpc-external/services/resource/testing"
controller "github.com/hashicorp/consul/internal/controller"
"github.com/hashicorp/consul/internal/controller/cache"
"github.com/hashicorp/consul/internal/controller/cache/index"
"github.com/hashicorp/consul/internal/controller/cache/indexers"
"github.com/hashicorp/consul/internal/controller/dependency"
"github.com/hashicorp/consul/internal/resource"
"github.com/hashicorp/consul/internal/resource/demo"
"github.com/hashicorp/consul/internal/resource/resourcetest"
"github.com/hashicorp/consul/proto-public/pbresource"
pbdemov1 "github.com/hashicorp/consul/proto/private/pbdemo/v1"
pbdemov2 "github.com/hashicorp/consul/proto/private/pbdemo/v2"
"github.com/hashicorp/consul/proto/private/prototest"
"github.com/hashicorp/consul/sdk/testutil"
"github.com/hashicorp/consul/sdk/testutil/retry"
)
var injectedError = errors.New("injected error")
func errQuery(_ cache.ReadOnlyCache, _ ...any) (cache.ResourceIterator, error) {
return nil, injectedError
}
func TestController_API(t *testing.T) {
t.Parallel()
idx := indexers.DecodedSingleIndexer("genre", index.SingleValueFromArgs(func(value string) ([]byte, error) {
var b index.Builder
b.String(value)
return b.Bytes(), nil
}), func(res *resource.DecodedResource[*pbdemov2.Artist]) (bool, []byte, error) {
var b index.Builder
b.String(res.Data.Genre.String())
return true, b.Bytes(), nil
})
rec := newTestReconciler()
init := newTestInitializer(1)
client := svctest.NewResourceServiceBuilder().
WithRegisterFns(demo.RegisterTypes).
Run(t)
concertsChan := make(chan controller.Event)
defer close(concertsChan)
concertSource := &controller.Source{Source: concertsChan}
concertMapper := func(ctx context.Context, rt controller.Runtime, event controller.Event) ([]controller.Request, error) {
artistID := event.Obj.(*Concert).artistID
var requests []controller.Request
requests = append(requests, controller.Request{ID: artistID})
return requests, nil
}
ctrl := controller.
NewController("artist", pbdemov2.ArtistType, idx).
WithWatch(pbdemov2.AlbumType, dependency.MapOwner, indexers.OwnerIndex("owner")).
WithQuery("some-query", errQuery).
WithCustomWatch(concertSource, concertMapper).
WithBackoff(10*time.Millisecond, 100*time.Millisecond).
WithReconciler(rec).
WithInitializer(init)
mgr := controller.NewManager(client, testutil.Logger(t))
mgr.Register(ctrl)
mgr.SetRaftLeader(true)
go mgr.Run(testContext(t))
// Wait for initialization to complete
init.wait(t)
t.Run("managed resource type", func(t *testing.T) {
res, err := demo.GenerateV2Artist()
require.NoError(t, err)
rsp, err := client.Write(testContext(t), &pbresource.WriteRequest{Resource: res})
require.NoError(t, err)
rt, req := rec.wait(t)
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
// ensure that the cache index is being properly managed
dec := resourcetest.MustDecode[*pbdemov2.Artist](t, res)
resources, err := rt.Cache.List(pbdemov2.ArtistType, "genre", dec.Data.Genre.String())
require.NoError(t, err)
prototest.AssertElementsMatch(t, []*pbresource.Resource{rsp.Resource}, resources)
// ensure that the query was successfully registered - as we should not do equality
// checks on functions we are using a constant error return query to ensure it was
// registered properly.
iter, err := rt.Cache.Query("some-query", "irrelevant")
require.ErrorIs(t, err, injectedError)
require.Nil(t, iter)
})
t.Run("watched resource type", func(t *testing.T) {
res, err := demo.GenerateV2Artist()
require.NoError(t, err)
rsp, err := client.Write(testContext(t), &pbresource.WriteRequest{Resource: res})
require.NoError(t, err)
_, req := rec.wait(t)
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
rec.expectNoRequest(t, 500*time.Millisecond)
album, err := demo.GenerateV2Album(rsp.Resource.Id)
require.NoError(t, err)
albumRsp1, err := client.Write(testContext(t), &pbresource.WriteRequest{Resource: album})
require.NoError(t, err)
_, req = rec.wait(t)
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
album2, err := demo.GenerateV2Album(rsp.Resource.Id)
require.NoError(t, err)
albumRsp2, err := client.Write(testContext(t), &pbresource.WriteRequest{Resource: album2})
require.NoError(t, err)
rt, req := rec.wait(t)
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
// ensure that the watched type cache is being updated
resources, err := rt.Cache.List(pbdemov2.AlbumType, "owner", rsp.Resource.Id)
require.NoError(t, err)
prototest.AssertElementsMatch(t, []*pbresource.Resource{albumRsp1.Resource, albumRsp2.Resource}, resources)
})
t.Run("custom watched resource type", func(t *testing.T) {
res, err := demo.GenerateV2Artist()
require.NoError(t, err)
rsp, err := client.Write(testContext(t), &pbresource.WriteRequest{Resource: res})
require.NoError(t, err)
_, req := rec.wait(t)
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
rec.expectNoRequest(t, 500*time.Millisecond)
concertsChan <- controller.Event{Obj: &Concert{name: "test-concert", artistID: rsp.Resource.Id}}
_, watchedReq := rec.wait(t)
prototest.AssertDeepEqual(t, req.ID, watchedReq.ID)
otherArtist, err := demo.GenerateV2Artist()
require.NoError(t, err)
concertsChan <- controller.Event{Obj: &Concert{name: "test-concert", artistID: otherArtist.Id}}
_, watchedReq = rec.wait(t)
prototest.AssertDeepEqual(t, otherArtist.Id, watchedReq.ID)
})
t.Run("error retries", func(t *testing.T) {
rec.failNext(errors.New("KABOOM"))
res, err := demo.GenerateV2Artist()
require.NoError(t, err)
rsp, err := client.Write(testContext(t), &pbresource.WriteRequest{Resource: res})
require.NoError(t, err)
_, req := rec.wait(t)
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
// Reconciler should be called with the same request again.
_, req = rec.wait(t)
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
})
t.Run("panic retries", func(t *testing.T) {
rec.panicNext("KABOOM")
res, err := demo.GenerateV2Artist()
require.NoError(t, err)
rsp, err := client.Write(testContext(t), &pbresource.WriteRequest{Resource: res})
require.NoError(t, err)
_, req := rec.wait(t)
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
// Reconciler should be called with the same request again.
_, req = rec.wait(t)
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
})
t.Run("defer", func(t *testing.T) {
rec.failNext(controller.RequeueAfter(1 * time.Second))
res, err := demo.GenerateV2Artist()
require.NoError(t, err)
rsp, err := client.Write(testContext(t), &pbresource.WriteRequest{Resource: res})
require.NoError(t, err)
_, req := rec.wait(t)
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
rec.expectNoRequest(t, 750*time.Millisecond)
_, req = rec.wait(t)
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
})
}
func TestController_API_InitializeRetry(t *testing.T) {
t.Parallel()
// Configure initializer to error initially in order to test retries
expectedInitAttempts := 2
init := newTestInitializer(expectedInitAttempts)
client := svctest.NewResourceServiceBuilder().
WithRegisterFns(demo.RegisterTypes).
Run(t)
rec := newTestReconciler()
ctrl := controller.
NewController("artist", pbdemov2.ArtistType).
WithBackoff(10*time.Millisecond, 100*time.Millisecond).
WithReconciler(rec).
WithInitializer(init)
mgr := controller.NewManager(client, testutil.Logger(t))
mgr.Register(ctrl)
mgr.SetRaftLeader(true)
go mgr.Run(testContext(t))
// Wait for initialization attempts to complete
for i := 0; i < expectedInitAttempts; i++ {
init.wait(t)
}
// Create a resource and expect it to reconcile now that initialization is complete
res, err := demo.GenerateV2Artist()
require.NoError(t, err)
_, err = client.Write(testContext(t), &pbresource.WriteRequest{Resource: res})
require.NoError(t, err)
rec.wait(t)
}
func waitForAtomicBoolValue(t testutil.TestingTB, actual *atomic.Bool, expected bool) {
t.Helper()
retry.Run(t, func(r *retry.R) {
require.Equal(r, expected, actual.Load())
})
}
func TestController_WithForceReconcileEvery_UpsertSuccess(t *testing.T) {
if testing.Short() {
t.Skip("too slow for testing.Short")
}
t.Parallel()
// Given a controller
// When the controller reconciles a resource due to an upsert and succeeds
// Then the controller manager should scheduled a forced reconcile after forceReconcileEvery
rec := newTestReconciler()
client := svctest.NewResourceServiceBuilder().
WithRegisterFns(demo.RegisterTypes).
Run(t)
// Create sizeable gap between reconcile #1 and forced reconcile #2 to ensure the delay occurs
forceReconcileEvery := 5 * time.Second
ctrl := controller.
NewController("artist", pbdemov2.ArtistType).
WithLogger(testutil.Logger(t)).
WithForceReconcileEvery(forceReconcileEvery).
WithReconciler(rec)
mgr := controller.NewManager(client, testutil.Logger(t))
mgr.Register(ctrl)
mgr.SetRaftLeader(true)
go mgr.Run(testContext(t))
res, err := demo.GenerateV2Artist()
require.NoError(t, err)
rsp, err := client.Write(testContext(t), &pbresource.WriteRequest{Resource: res})
require.NoError(t, err)
// Verify reconcile #1 happens immediately
_, req := rec.waitFor(t, time.Second)
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
// Verify no reconciles occur between reconcile #1 and forced reconcile #2.
// Remove a second for max jitter (20% of 5s) and one more second to be safe.
rec.expectNoRequest(t, forceReconcileEvery-time.Second-time.Second)
// Verify forced reconcile #2 occurred (forceReconcileEvery - 1s - 1s + 3s > forceReconcileEvery)
_, req = rec.waitFor(t, time.Second*3)
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
}
func TestController_WithForceReconcileEvery_SkipOnReconcileError(t *testing.T) {
if testing.Short() {
t.Skip("too slow for testing.Short")
}
t.Parallel()
// Given a controller configured with a forceReconcileEvery duration
// When the controller reconciles a resource due to an upsert and returns an error
// Then the controller manager should not schedule a forced reconcile and allow
// the existing error handling to schedule a rate-limited retry
rec := newTestReconciler()
client := svctest.NewResourceServiceBuilder().
WithRegisterFns(demo.RegisterTypes).
Run(t)
// Large enough gap to test for a period of no-reconciles
forceReconcileEvery := 5 * time.Second
ctrl := controller.
NewController("artist", pbdemov2.ArtistType).
WithLogger(testutil.Logger(t)).
WithForceReconcileEvery(forceReconcileEvery).
WithReconciler(rec)
mgr := controller.NewManager(client, testutil.Logger(t))
mgr.Register(ctrl)
mgr.SetRaftLeader(true)
go mgr.Run(testContext(t))
res, err := demo.GenerateV2Artist()
require.NoError(t, err)
// Setup reconcile #1 to fail
rec.failNext(errors.New("reconcile #1 error"))
rsp, err := client.Write(testContext(t), &pbresource.WriteRequest{Resource: res})
require.NoError(t, err)
// Observe failed reconcile #1
_, req := rec.wait(t)
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
// Observe successful (rate-limited retry) reconcile #2. By not failNext'ing it,
// we're expecting it now'ish.
_, req = rec.wait(t)
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
// Observe no forced reconcile for gap after last successful reconcile
// -1s for 20% jitter reduction
// -1s for just to be safe
rec.expectNoRequest(t, forceReconcileEvery-time.Second-time.Second)
// Finally observe forced reconcile #3 up to 1 sec past (5-1-1+3) accumulated forceReconcileEvery delay
_, req = rec.waitFor(t, 3*time.Second)
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
}
func TestController_WithForceReconcileEvery_SkipOnDelete(t *testing.T) {
if testing.Short() {
t.Skip("too slow for testing.Short")
}
t.Parallel()
// Given a controller configured with a forceReconcileEvery duration
// When the controller reconciles a resource due to a delete and succeeds
// Then the controller manager should not schedule a forced reconcile
rec := newTestReconciler()
client := svctest.NewResourceServiceBuilder().
WithRegisterFns(demo.RegisterTypes).
Run(t)
// Large enough gap to test for a period of no-reconciles
forceReconcileEvery := 5 * time.Second
ctrl := controller.
NewController("artist", pbdemov2.ArtistType).
WithLogger(testutil.Logger(t)).
WithForceReconcileEvery(forceReconcileEvery).
WithReconciler(rec)
mgr := controller.NewManager(client, testutil.Logger(t))
mgr.Register(ctrl)
mgr.SetRaftLeader(true)
go mgr.Run(testContext(t))
res, err := demo.GenerateV2Artist()
require.NoError(t, err)
rsp, err := client.Write(testContext(t), &pbresource.WriteRequest{Resource: res})
require.NoError(t, err)
// Account for reconcile #1 due to initial write
_, req := rec.wait(t)
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
// Perform a delete
_, err = client.Delete(testContext(t), &pbresource.DeleteRequest{Id: rsp.Resource.Id})
require.NoError(t, err)
// Account for the reconcile #2 due to the delete
_, req = rec.wait(t)
// Account for the deferred forced reconcile #3 from the original write event since deferred
// reconciles don't seem to be de-duped against non-deferred reconciles.
_, req = rec.waitFor(t, forceReconcileEvery)
// Verify no further reconciles occur
rec.expectNoRequest(t, forceReconcileEvery)
}
func TestController_Placement(t *testing.T) {
t.Parallel()
t.Run("singleton", func(t *testing.T) {
var running atomic.Bool
running.Store(false)
rec := newTestReconciler()
client := svctest.NewResourceServiceBuilder().
WithRegisterFns(demo.RegisterTypes).
WithCloningDisabled().
Run(t)
ctrl := controller.
NewController("artist", pbdemov2.ArtistType).
WithWatch(pbdemov2.AlbumType, dependency.MapOwner).
WithPlacement(controller.PlacementSingleton).
WithNotifyStart(func(context.Context, controller.Runtime) {
running.Store(true)
}).
WithNotifyStop(func(context.Context, controller.Runtime) {
running.Store(false)
}).
WithReconciler(rec)
mgr := controller.NewManager(client, testutil.Logger(t))
mgr.Register(ctrl)
go mgr.Run(testContext(t))
res, err := demo.GenerateV2Artist()
require.NoError(t, err)
// Reconciler should not be called until we're the Raft leader.
_, err = client.Write(testContext(t), &pbresource.WriteRequest{Resource: res})
require.NoError(t, err)
rec.expectNoRequest(t, 500*time.Millisecond)
// Become the leader and check the reconciler is called.
mgr.SetRaftLeader(true)
waitForAtomicBoolValue(t, &running, true)
_, _ = rec.wait(t)
// Should not be called after losing leadership.
mgr.SetRaftLeader(false)
waitForAtomicBoolValue(t, &running, false)
_, err = client.Write(testContext(t), &pbresource.WriteRequest{Resource: res})
require.NoError(t, err)
rec.expectNoRequest(t, 500*time.Millisecond)
})
t.Run("each server", func(t *testing.T) {
var running atomic.Bool
running.Store(false)
rec := newTestReconciler()
client := svctest.NewResourceServiceBuilder().
WithRegisterFns(demo.RegisterTypes).
Run(t)
ctrl := controller.
NewController("artist", pbdemov2.ArtistType).
WithWatch(pbdemov2.AlbumType, dependency.MapOwner).
WithPlacement(controller.PlacementEachServer).
WithNotifyStart(func(context.Context, controller.Runtime) {
running.Store(true)
}).
WithReconciler(rec)
mgr := controller.NewManager(client, testutil.Logger(t))
mgr.Register(ctrl)
go mgr.Run(testContext(t))
waitForAtomicBoolValue(t, &running, true)
res, err := demo.GenerateV2Artist()
require.NoError(t, err)
// Reconciler should be called even though we're not the Raft leader.
_, err = client.Write(testContext(t), &pbresource.WriteRequest{Resource: res})
require.NoError(t, err)
_, _ = rec.wait(t)
})
}
func TestController_String(t *testing.T) {
ctrl := controller.
NewController("artist", pbdemov2.ArtistType).
WithWatch(pbdemov2.AlbumType, dependency.MapOwner).
WithBackoff(5*time.Second, 1*time.Hour).
WithPlacement(controller.PlacementEachServer)
require.Equal(t,
`<Controller managed_type=demo.v2.Artist, watched_types=[demo.v2.Album], backoff=<base=5s, max=1h0m0s>, placement=each-server>`,
ctrl.String(),
)
}
func TestController_NoReconciler(t *testing.T) {
client := svctest.NewResourceServiceBuilder().
WithRegisterFns(demo.RegisterTypes).
Run(t)
mgr := controller.NewManager(client, testutil.Logger(t))
ctrl := controller.NewController("artist", pbdemov2.ArtistType)
require.PanicsWithValue(t,
fmt.Sprintf("cannot register controller without a reconciler %s", ctrl.String()),
func() { mgr.Register(ctrl) })
}
func TestController_Watch(t *testing.T) {
t.Parallel()
t.Run("partitioned scoped resources", func(t *testing.T) {
rec := newTestReconciler()
client := svctest.NewResourceServiceBuilder().
WithRegisterFns(demo.RegisterTypes).
Run(t)
ctrl := controller.
NewController("labels", pbdemov1.RecordLabelType).
WithReconciler(rec)
mgr := controller.NewManager(client, testutil.Logger(t))
mgr.SetRaftLeader(true)
mgr.Register(ctrl)
ctx := testContext(t)
go mgr.Run(ctx)
res, err := demo.GenerateV1RecordLabel("test")
require.NoError(t, err)
rsp, err := client.Write(testContext(t), &pbresource.WriteRequest{Resource: res})
require.NoError(t, err)
_, req := rec.wait(t)
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
})
t.Run("cluster scoped resources", func(t *testing.T) {
rec := newTestReconciler()
client := svctest.NewResourceServiceBuilder().
WithRegisterFns(demo.RegisterTypes).
Run(t)
ctrl := controller.
NewController("executives", pbdemov1.ExecutiveType).
WithReconciler(rec)
mgr := controller.NewManager(client, testutil.Logger(t))
mgr.SetRaftLeader(true)
mgr.Register(ctrl)
go mgr.Run(testContext(t))
exec, err := demo.GenerateV1Executive("test", "CEO")
require.NoError(t, err)
rsp, err := client.Write(testContext(t), &pbresource.WriteRequest{Resource: exec})
require.NoError(t, err)
_, req := rec.wait(t)
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
})
t.Run("namespace scoped resources", func(t *testing.T) {
rec := newTestReconciler()
client := svctest.NewResourceServiceBuilder().
WithRegisterFns(demo.RegisterTypes).
Run(t)
ctrl := controller.
NewController("artists", pbdemov2.ArtistType).
WithReconciler(rec)
mgr := controller.NewManager(client, testutil.Logger(t))
mgr.SetRaftLeader(true)
mgr.Register(ctrl)
go mgr.Run(testContext(t))
artist, err := demo.GenerateV2Artist()
require.NoError(t, err)
rsp, err := client.Write(testContext(t), &pbresource.WriteRequest{Resource: artist})
require.NoError(t, err)
_, req := rec.wait(t)
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
})
}
func newTestReconciler() *testReconciler {
return &testReconciler{
calls: make(chan requestArgs),
errors: make(chan error, 1),
panics: make(chan any, 1),
}
}
type requestArgs struct {
req controller.Request
rt controller.Runtime
}
type testReconciler struct {
calls chan requestArgs
errors chan error
panics chan any
}
func (r *testReconciler) Reconcile(_ context.Context, rt controller.Runtime, req controller.Request) error {
r.calls <- requestArgs{req: req, rt: rt}
select {
case err := <-r.errors:
return err
case p := <-r.panics:
panic(p)
default:
return nil
}
}
func (r *testReconciler) failNext(err error) {
r.errors <- err
}
func (r *testReconciler) panicNext(p any) {
r.panics <- p
}
func (r *testReconciler) expectNoRequest(t *testing.T, duration time.Duration) {
t.Helper()
started := time.Now()
select {
case args := <-r.calls:
t.Fatalf("expected no request for %s, but got: %s after %s", duration, args.req.ID, time.Since(started))
case <-time.After(duration):
}
}
func (r *testReconciler) wait(t *testing.T) (controller.Runtime, controller.Request) {
t.Helper()
return r.waitFor(t, 500*time.Millisecond)
}
func (r *testReconciler) waitFor(t *testing.T, duration time.Duration) (controller.Runtime, controller.Request) {
t.Helper()
var args requestArgs
select {
case args = <-r.calls:
case <-time.After(duration):
t.Fatalf("Reconcile was not called after %v", duration)
}
return args.rt, args.req
}
func testContext(t *testing.T) context.Context {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
return ctx
}
type Concert struct {
name string
artistID *pbresource.ID
}
func (c Concert) Key() string {
return c.name
}
func newTestInitializer(errorCount int) *testInitializer {
return &testInitializer{
calls: make(chan error, 1),
expectedAttempts: errorCount,
}
}
type testInitializer struct {
expectedAttempts int // number of times the initializer should run to test retries
attempts int // running count of times initialize is called
calls chan error
}
func (i *testInitializer) Initialize(_ context.Context, _ controller.Runtime) error {
i.attempts++
if i.attempts < i.expectedAttempts {
// Return an error to cause a retry
err := errors.New("initialization error")
i.calls <- err
return err
} else {
i.calls <- nil
return nil
}
}
func (i *testInitializer) wait(t *testing.T) {
t.Helper()
select {
case err := <-i.calls:
if err == nil {
// Initialize did not error, no more calls should be expected
close(i.calls)
}
return
case <-time.After(1000 * time.Millisecond):
t.Fatal("Initialize was not called after 1000ms")
}
}