mirror of https://github.com/hashicorp/consul
Dan Upton
2 years ago
committed by
GitHub
10 changed files with 952 additions and 17 deletions
@ -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