mirror of https://github.com/hashicorp/consul
parent
5e4b736b70
commit
6e1bc57469
@ -0,0 +1,268 @@
|
|||||||
|
// Copyright (c) HashiCorp, Inc.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package controller_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
svctest "github.com/hashicorp/consul/agent/grpc-external/services/resource/testing"
|
||||||
|
"github.com/hashicorp/consul/internal/controller"
|
||||||
|
"github.com/hashicorp/consul/internal/resource/demo"
|
||||||
|
"github.com/hashicorp/consul/proto-public/pbresource"
|
||||||
|
"github.com/hashicorp/consul/proto/private/prototest"
|
||||||
|
"github.com/hashicorp/consul/sdk/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestController_API(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := newTestReconciler()
|
||||||
|
client := svctest.RunResourceService(t, demo.RegisterTypes)
|
||||||
|
|
||||||
|
ctrl := controller.
|
||||||
|
ForType(demo.TypeV2Artist).
|
||||||
|
WithWatch(demo.TypeV2Album, controller.MapOwner).
|
||||||
|
WithBackoff(10*time.Millisecond, 100*time.Millisecond).
|
||||||
|
WithReconciler(rec)
|
||||||
|
|
||||||
|
mgr := controller.NewManager(client, testutil.Logger(t))
|
||||||
|
mgr.Register(ctrl)
|
||||||
|
mgr.SetRaftLeader(true)
|
||||||
|
go mgr.Run(testContext(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)
|
||||||
|
|
||||||
|
req := rec.wait(t)
|
||||||
|
prototest.AssertDeepEqual(t, rsp.Resource.Id, req.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
_, 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
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_Placement(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("singleton", func(t *testing.T) {
|
||||||
|
rec := newTestReconciler()
|
||||||
|
client := svctest.RunResourceService(t, demo.RegisterTypes)
|
||||||
|
|
||||||
|
ctrl := controller.
|
||||||
|
ForType(demo.TypeV2Artist).
|
||||||
|
WithWatch(demo.TypeV2Album, controller.MapOwner).
|
||||||
|
WithPlacement(controller.PlacementSingleton).
|
||||||
|
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)
|
||||||
|
_ = rec.wait(t)
|
||||||
|
|
||||||
|
// Should not be called after losing leadership.
|
||||||
|
mgr.SetRaftLeader(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) {
|
||||||
|
rec := newTestReconciler()
|
||||||
|
client := svctest.RunResourceService(t, demo.RegisterTypes)
|
||||||
|
|
||||||
|
ctrl := controller.
|
||||||
|
ForType(demo.TypeV2Artist).
|
||||||
|
WithWatch(demo.TypeV2Album, controller.MapOwner).
|
||||||
|
WithPlacement(controller.PlacementEachServer).
|
||||||
|
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 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.
|
||||||
|
ForType(demo.TypeV2Artist).
|
||||||
|
WithWatch(demo.TypeV2Album, controller.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.RunResourceService(t, demo.RegisterTypes)
|
||||||
|
mgr := controller.NewManager(client, testutil.Logger(t))
|
||||||
|
|
||||||
|
ctrl := controller.ForType(demo.TypeV2Artist)
|
||||||
|
require.PanicsWithValue(t,
|
||||||
|
`cannot register controller without a reconciler <Controller managed_type="demo.v2.artist", watched_types=[], backoff=<base="5ms", max="16m40s">, placement="singleton">`,
|
||||||
|
func() { mgr.Register(ctrl) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestReconciler() *testReconciler {
|
||||||
|
return &testReconciler{
|
||||||
|
calls: make(chan controller.Request),
|
||||||
|
errors: make(chan error, 1),
|
||||||
|
panics: make(chan any, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type testReconciler struct {
|
||||||
|
calls chan controller.Request
|
||||||
|
errors chan error
|
||||||
|
panics chan any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *testReconciler) Reconcile(_ context.Context, _ controller.Runtime, req controller.Request) error {
|
||||||
|
r.calls <- req
|
||||||
|
|
||||||
|
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 req := <-r.calls:
|
||||||
|
t.Fatalf("expected no request for %s, but got: %s after %s", duration, req.ID, time.Since(started))
|
||||||
|
case <-time.After(duration):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *testReconciler) wait(t *testing.T) controller.Request {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var req controller.Request
|
||||||
|
select {
|
||||||
|
case req = <-r.calls:
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
t.Fatal("Reconcile was not called after 500ms")
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func testContext(t *testing.T) context.Context {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
// Copyright (c) HashiCorp, Inc.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
// Package controller provides an API for implementing control loops on top of
|
||||||
|
// Consul resources. It is heavily inspired by [Kubebuilder] and the Kubernetes
|
||||||
|
// [controller runtime].
|
||||||
|
//
|
||||||
|
// [Kubebuilder]: https://github.com/kubernetes-sigs/kubebuilder
|
||||||
|
// [controller runtime]: https://github.com/kubernetes-sigs/controller-runtime
|
||||||
|
package controller
|
@ -0,0 +1,102 @@
|
|||||||
|
// Copyright (c) HashiCorp, Inc.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package demo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
svctest "github.com/hashicorp/consul/agent/grpc-external/services/resource/testing"
|
||||||
|
"github.com/hashicorp/consul/internal/controller"
|
||||||
|
"github.com/hashicorp/consul/proto-public/pbresource"
|
||||||
|
pbdemov2 "github.com/hashicorp/consul/proto/private/pbdemo/v2"
|
||||||
|
"github.com/hashicorp/consul/sdk/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestArtistReconciler(t *testing.T) {
|
||||||
|
client := svctest.RunResourceService(t, RegisterTypes)
|
||||||
|
|
||||||
|
// Seed the database with an artist.
|
||||||
|
res, err := GenerateV2Artist()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set the genre to BLUES to ensure there are 10 albums.
|
||||||
|
var artist pbdemov2.Artist
|
||||||
|
require.NoError(t, res.Data.UnmarshalTo(&artist))
|
||||||
|
artist.Genre = pbdemov2.Genre_GENRE_BLUES
|
||||||
|
require.NoError(t, res.Data.MarshalFrom(&artist))
|
||||||
|
|
||||||
|
ctx := testutil.TestContext(t)
|
||||||
|
writeRsp, err := client.Write(ctx, &pbresource.WriteRequest{Resource: res})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Call the reconciler for that artist.
|
||||||
|
var rec artistReconciler
|
||||||
|
runtime := controller.Runtime{
|
||||||
|
Client: client,
|
||||||
|
Logger: testutil.Logger(t),
|
||||||
|
}
|
||||||
|
req := controller.Request{
|
||||||
|
ID: writeRsp.Resource.Id,
|
||||||
|
}
|
||||||
|
require.NoError(t, rec.Reconcile(ctx, runtime, req))
|
||||||
|
|
||||||
|
// Check the status was updated.
|
||||||
|
readRsp, err := client.Read(ctx, &pbresource.ReadRequest{Id: writeRsp.Resource.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, readRsp.Resource.Status, "consul.io/artist-controller")
|
||||||
|
|
||||||
|
status := readRsp.Resource.Status["consul.io/artist-controller"]
|
||||||
|
require.Equal(t, writeRsp.Resource.Generation, status.ObservedGeneration)
|
||||||
|
require.Len(t, status.Conditions, 11)
|
||||||
|
require.Equal(t, "Accepted", status.Conditions[0].Type)
|
||||||
|
require.Equal(t, "AlbumCreated", status.Conditions[1].Type)
|
||||||
|
|
||||||
|
// Check the albums were created.
|
||||||
|
listRsp, err := client.List(ctx, &pbresource.ListRequest{
|
||||||
|
Type: TypeV2Album,
|
||||||
|
Tenancy: readRsp.Resource.Id.Tenancy,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, listRsp.Resources, 10)
|
||||||
|
|
||||||
|
// Delete an album.
|
||||||
|
_, err = client.Delete(ctx, &pbresource.DeleteRequest{Id: listRsp.Resources[0].Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Call the reconciler again.
|
||||||
|
require.NoError(t, rec.Reconcile(ctx, runtime, req))
|
||||||
|
|
||||||
|
// Check the album was recreated.
|
||||||
|
listRsp, err = client.List(ctx, &pbresource.ListRequest{
|
||||||
|
Type: TypeV2Album,
|
||||||
|
Tenancy: readRsp.Resource.Id.Tenancy,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, listRsp.Resources, 10)
|
||||||
|
|
||||||
|
// Set the genre to DISCO.
|
||||||
|
readRsp, err = client.Read(ctx, &pbresource.ReadRequest{Id: writeRsp.Resource.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
res = readRsp.Resource
|
||||||
|
require.NoError(t, res.Data.UnmarshalTo(&artist))
|
||||||
|
artist.Genre = pbdemov2.Genre_GENRE_DISCO
|
||||||
|
require.NoError(t, res.Data.MarshalFrom(&artist))
|
||||||
|
|
||||||
|
_, err = client.Write(ctx, &pbresource.WriteRequest{Resource: res})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Call the reconciler again.
|
||||||
|
require.NoError(t, rec.Reconcile(ctx, runtime, req))
|
||||||
|
|
||||||
|
// Check there are only 3 albums now.
|
||||||
|
listRsp, err = client.List(ctx, &pbresource.ListRequest{
|
||||||
|
Type: TypeV2Album,
|
||||||
|
Tenancy: readRsp.Resource.Id.Tenancy,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, listRsp.Resources, 3)
|
||||||
|
}
|
Loading…
Reference in new issue