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{}