github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/controllers/fake/fixture.go (about)

     1  package fake
     2  
     3  import (
     4  	"context"
     5  	"io"
     6  	"os"
     7  	"strings"
     8  	"sync"
     9  	"testing"
    10  
    11  	"github.com/stretchr/testify/assert"
    12  	"github.com/stretchr/testify/require"
    13  	apierrors "k8s.io/apimachinery/pkg/api/errors"
    14  	"k8s.io/apimachinery/pkg/runtime"
    15  	"k8s.io/apimachinery/pkg/types"
    16  	ctrl "sigs.k8s.io/controller-runtime"
    17  	"sigs.k8s.io/controller-runtime/pkg/builder"
    18  	"sigs.k8s.io/controller-runtime/pkg/cache"
    19  	ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
    20  	"sigs.k8s.io/controller-runtime/pkg/reconcile"
    21  	"sigs.k8s.io/controller-runtime/pkg/source"
    22  
    23  	"github.com/tilt-dev/tilt-apiserver/pkg/server/builder/resource"
    24  	"github.com/tilt-dev/tilt/internal/controllers/indexer"
    25  	"github.com/tilt-dev/tilt/internal/store"
    26  	"github.com/tilt-dev/tilt/internal/testutils"
    27  	"github.com/tilt-dev/tilt/internal/testutils/bufsync"
    28  	"github.com/tilt-dev/wmclient/pkg/analytics"
    29  )
    30  
    31  // controller just exists to prevent an import cycle for controllers.
    32  // It's not exported and should match the minimal set of methods needed from controllers.Controller.
    33  type controller interface {
    34  	reconcile.Reconciler
    35  	CreateBuilder(mgr ctrl.Manager) (*builder.Builder, error)
    36  }
    37  
    38  // object just bridges together a couple of different representations of runtime.Object.
    39  // Scaffolded/code-generated types should meet this by default.
    40  type object interface {
    41  	ctrlclient.Object
    42  	resource.Object
    43  }
    44  
    45  type ControllerFixture struct {
    46  	t          testing.TB
    47  	out        *bufsync.ThreadSafeBuffer
    48  	ctx        context.Context
    49  	cancel     context.CancelFunc
    50  	controller reconcile.Reconciler
    51  	Store      *testStore
    52  	Scheme     *runtime.Scheme
    53  	Client     ctrlclient.Client
    54  }
    55  
    56  type ControllerFixtureBuilder struct {
    57  	t                  testing.TB
    58  	ctx                context.Context
    59  	cancel             context.CancelFunc
    60  	out                *bufsync.ThreadSafeBuffer
    61  	ma                 *analytics.MemoryAnalytics
    62  	Client             ctrlclient.Client
    63  	Store              *testStore
    64  	requeuer           source.Source
    65  	requeuerResultChan chan indexer.RequeueForTestResult
    66  }
    67  
    68  func NewControllerFixtureBuilder(t testing.TB) *ControllerFixtureBuilder {
    69  	outBuf := bufsync.NewThreadSafeBuffer()
    70  
    71  	out := io.MultiWriter(outBuf, os.Stdout)
    72  	ctx, ma, _ := testutils.ForkedCtxAndAnalyticsForTest(out)
    73  
    74  	ctx, cancel := context.WithCancel(ctx)
    75  	t.Cleanup(cancel)
    76  
    77  	return &ControllerFixtureBuilder{
    78  		t:      t,
    79  		ctx:    ctx,
    80  		cancel: cancel,
    81  		out:    outBuf,
    82  		ma:     ma,
    83  		Client: NewFakeTiltClient(),
    84  		Store:  NewTestingStore(out),
    85  	}
    86  }
    87  
    88  func (b *ControllerFixtureBuilder) WithRequeuer(r source.Source) *ControllerFixtureBuilder {
    89  	b.requeuer = r
    90  	return b
    91  }
    92  
    93  func (b *ControllerFixtureBuilder) WithRequeuerResultChan(ch chan indexer.RequeueForTestResult) *ControllerFixtureBuilder {
    94  	b.requeuerResultChan = ch
    95  	return b
    96  }
    97  
    98  func (b *ControllerFixtureBuilder) Scheme() *runtime.Scheme {
    99  	return b.Client.Scheme()
   100  }
   101  
   102  func (b *ControllerFixtureBuilder) Analytics() *analytics.MemoryAnalytics {
   103  	return b.ma
   104  }
   105  
   106  func (b *ControllerFixtureBuilder) Build(c controller) *ControllerFixture {
   107  	b.t.Helper()
   108  
   109  	// apiserver controller initialization is awkward and some parts are done via the builder,
   110  	// so we call it here even though we won't actually use the builder result
   111  	// currently, this relies on the fact that no controllers actually use the
   112  	// controllerruntime.Manager argument for anything besides passing it along - if that changes,
   113  	// we'll need to provide a mock of it that implements the requisite functionality
   114  	_, err := c.CreateBuilder(&FakeManager{})
   115  	require.NoError(b.t, err, "Error in controller CreateBuilder()")
   116  
   117  	// In a normal controller, there's a central reconciliation loop
   118  	// that ensures we never have two reconcile() calls running simultaneously.
   119  	//
   120  	// In our test code, we want to people to invoke Reconcile() directly and in
   121  	// the background.  So instead, we wrap the Reconcile() call in mutex.
   122  	lc := NewLockedController(c)
   123  	if b.requeuer != nil {
   124  		indexer.StartSourceForTesting(b.Context(), b.requeuer, lc, b.requeuerResultChan)
   125  	}
   126  
   127  	return &ControllerFixture{
   128  		t:          b.t,
   129  		out:        b.out,
   130  		ctx:        b.ctx,
   131  		cancel:     b.cancel,
   132  		Scheme:     b.Client.Scheme(),
   133  		Client:     b.Client,
   134  		Store:      b.Store,
   135  		controller: lc,
   136  	}
   137  }
   138  
   139  func (b *ControllerFixtureBuilder) OutWriter() io.Writer {
   140  	return b.out
   141  }
   142  
   143  func (b *ControllerFixtureBuilder) Context() context.Context {
   144  	return b.ctx
   145  }
   146  
   147  func (b *ControllerFixture) Stdout() string {
   148  	return b.out.String()
   149  }
   150  
   151  func (f *ControllerFixture) T() testing.TB {
   152  	return f.t
   153  }
   154  
   155  // Cancel cancels the internal context used for the controller and client requests.
   156  //
   157  // Normally, it's not necessary to call this - the fixture will automatically cancel the context as part of test
   158  // cleanup to avoid leaking resources. However, if you want to explicitly test how a controller reacts to context
   159  // cancellation, this method can be used.
   160  func (f *ControllerFixture) Cancel() {
   161  	f.cancel()
   162  }
   163  
   164  func (f *ControllerFixture) Context() context.Context {
   165  	return f.ctx
   166  }
   167  
   168  func (f *ControllerFixture) KeyForObject(o object) types.NamespacedName {
   169  	return types.NamespacedName{Namespace: o.GetNamespace(), Name: o.GetName()}
   170  }
   171  
   172  func (f *ControllerFixture) MustReconcile(key types.NamespacedName) ctrl.Result {
   173  	f.t.Helper()
   174  	result, err := f.Reconcile(key)
   175  	require.NoError(f.t, err)
   176  	return result
   177  }
   178  
   179  func (f *ControllerFixture) Reconcile(key types.NamespacedName) (ctrl.Result, error) {
   180  	f.t.Helper()
   181  	return f.controller.Reconcile(f.ctx, ctrl.Request{NamespacedName: key})
   182  }
   183  
   184  func (f *ControllerFixture) ReconcileWithErrors(key types.NamespacedName, expectedErrorSubstrings ...string) {
   185  	f.t.Helper()
   186  	_, err := f.Reconcile(key)
   187  	require.Error(f.t, err)
   188  	for _, s := range expectedErrorSubstrings {
   189  		require.Contains(f.t, err.Error(), s)
   190  	}
   191  }
   192  
   193  func (f *ControllerFixture) Get(key types.NamespacedName, out object) bool {
   194  	f.t.Helper()
   195  	err := f.Client.Get(f.ctx, key, out)
   196  	if apierrors.IsNotFound(err) {
   197  		return false
   198  	}
   199  	require.NoError(f.t, err)
   200  	return true
   201  }
   202  
   203  func (f *ControllerFixture) MustGet(key types.NamespacedName, out object) {
   204  	f.t.Helper()
   205  	found := f.Get(key, out)
   206  	if !found {
   207  		// don't try to read from object Kind, it's probably not properly populated
   208  		f.t.Fatalf("%T object %q does not exist", out, key.String())
   209  	}
   210  }
   211  
   212  func (f *ControllerFixture) List(out ctrlclient.ObjectList) {
   213  	f.t.Helper()
   214  	err := f.Client.List(f.ctx, out)
   215  	require.NoError(f.t, err)
   216  }
   217  
   218  func (f *ControllerFixture) Create(o object) ctrl.Result {
   219  	f.t.Helper()
   220  	require.NoError(f.t, f.Client.Create(f.ctx, o))
   221  	return f.MustReconcile(f.KeyForObject(o))
   222  }
   223  
   224  // Update updates the object metadata and spec.
   225  func (f *ControllerFixture) Update(o object) ctrl.Result {
   226  	f.t.Helper()
   227  	require.NoError(f.t, f.Client.Update(f.ctx, o))
   228  	return f.MustReconcile(f.KeyForObject(o))
   229  }
   230  
   231  // Create or update.
   232  func (f *ControllerFixture) Upsert(o object) ctrl.Result {
   233  	f.t.Helper()
   234  
   235  	err := f.Client.Create(f.ctx, o)
   236  	if err != nil &&
   237  		(apierrors.IsAlreadyExists(err) ||
   238  			strings.Contains(err.Error(), "resourceVersion can not be set for Create requests")) {
   239  		tmp := o.DeepCopyObject().(object)
   240  
   241  		require.NoError(f.t, f.Client.Get(f.ctx, f.KeyForObject(o), tmp))
   242  		o.SetResourceVersion(tmp.GetResourceVersion())
   243  
   244  		var status resource.StatusSubResource
   245  		withStatus, hasStatus := o.(resource.ObjectWithStatusSubResource)
   246  		if hasStatus {
   247  			status = withStatus.DeepCopyObject().(resource.ObjectWithStatusSubResource).GetStatus()
   248  		}
   249  
   250  		result := f.Update(o)
   251  
   252  		if hasStatus {
   253  			status.CopyTo(withStatus)
   254  			return f.UpdateStatus(o)
   255  		}
   256  
   257  		return result
   258  	}
   259  	require.NoError(f.t, err)
   260  	return f.MustReconcile(f.KeyForObject(o))
   261  }
   262  
   263  func (f *ControllerFixture) UpdateStatus(o object) ctrl.Result {
   264  	f.t.Helper()
   265  	require.NoError(f.t, f.Client.Status().Update(f.ctx, o))
   266  	return f.MustReconcile(f.KeyForObject(o))
   267  }
   268  
   269  func (f *ControllerFixture) Delete(o object) (bool, ctrl.Result) {
   270  	f.t.Helper()
   271  	err := f.Client.Delete(f.ctx, o)
   272  	require.NoError(f.t, ctrlclient.IgnoreNotFound(err))
   273  	if apierrors.IsNotFound(err) {
   274  		// skip reconciliation since no object was deleted
   275  		return false, ctrl.Result{}
   276  	}
   277  	return true, f.MustReconcile(f.KeyForObject(o))
   278  }
   279  
   280  func (f *ControllerFixture) Actions() []store.Action {
   281  	return f.Store.Actions()
   282  }
   283  
   284  func (f *ControllerFixture) AssertStdOutContains(v string) bool {
   285  	f.t.Helper()
   286  	return assert.True(f.t, strings.Contains(f.Stdout(), v),
   287  		"Stdout did not include output: %q", v)
   288  }
   289  
   290  type FakeManager struct {
   291  	ctrl.Manager
   292  }
   293  
   294  func (m *FakeManager) GetCache() cache.Cache {
   295  	return nil
   296  }
   297  
   298  type LockedController struct {
   299  	mu         sync.Mutex
   300  	controller controller
   301  }
   302  
   303  func NewLockedController(c controller) *LockedController {
   304  	return &LockedController{controller: c}
   305  }
   306  
   307  func (c *LockedController) Reconcile(ctx context.Context, req reconcile.Request) (ctrl.Result, error) {
   308  	c.mu.Lock()
   309  	defer c.mu.Unlock()
   310  	return c.controller.Reconcile(ctx, req)
   311  }
   312  
   313  func (c *LockedController) CreateBuilder(mgr ctrl.Manager) (*builder.Builder, error) {
   314  	return c.controller.CreateBuilder(mgr)
   315  }
   316  
   317  var _ controller = &LockedController{}